diff --git a/LICENSE b/LICENSE index 74dadfa..f878bc8 100644 --- a/LICENSE +++ b/LICENSE @@ -1,417 +1,417 @@ -MARK 52 — JARVIS -Copyright (c) 2026 FatihMakes - -Licensed under the Creative Commons Attribution-NonCommercial 4.0 -International License (CC BY-NC 4.0). Commercial use is not permitted. -Canonical text: https://creativecommons.org/licenses/by-nc/4.0/legalcode - ------------------------------------------------------------------------ - -Attribution-NonCommercial 4.0 International - -======================================================================= - -Creative Commons Corporation ("Creative Commons") is not a law firm and -does not provide legal services or legal advice. Distribution of -Creative Commons public licenses does not create a lawyer-client or -other relationship. Creative Commons makes its licenses and related -information available on an "as-is" basis. Creative Commons gives no -warranties regarding its licenses, any material licensed under their -terms and conditions, or any related information. Creative Commons -disclaims all liability for damages resulting from their use to the -fullest extent possible. - -Using Creative Commons Public Licenses - -Creative Commons public licenses provide a standard set of terms and -conditions that creators and other rights holders may use to share -original works of authorship and other material subject to copyright -and certain other rights specified in the public license below. The -following considerations are for informational purposes only, are not -exhaustive, and do not form part of our licenses. - - Considerations for licensors: Our public licenses are - intended for use by those authorized to give the public - permission to use material in ways otherwise restricted by - copyright and certain other rights. Our licenses are - irrevocable. Licensors should read and understand the terms - and conditions of the license they choose before applying it. - Licensors should also secure all rights necessary before - applying our licenses so that the public can reuse the - material as expected. Licensors should clearly mark any - material not subject to the license. This includes other CC- - licensed material, or material used under an exception or - limitation to copyright. More considerations for licensors: - wiki.creativecommons.org/Considerations_for_licensors - - Considerations for the public: By using one of our public - licenses, a licensor grants the public permission to use the - licensed material under specified terms and conditions. If - the licensor's permission is not necessary for any reason--for - example, because of any applicable exception or limitation to - copyright--then that use is not regulated by the license. Our - licenses grant only permissions under copyright and certain - other rights that a licensor has authority to grant. Use of - the licensed material may still be restricted for other - reasons, including because others have copyright or other - rights in the material. A licensor may make special requests, - such as asking that all changes be marked or described. - Although not required by our licenses, you are encouraged to - respect those requests where reasonable. More considerations - for the public: - wiki.creativecommons.org/Considerations_for_licensees - -======================================================================= - -Creative Commons Attribution-NonCommercial 4.0 International Public -License - -By exercising the Licensed Rights (defined below), You accept and agree -to be bound by the terms and conditions of this Creative Commons -Attribution-NonCommercial 4.0 International Public License ("Public -License"). To the extent this Public License may be interpreted as a -contract, You are granted the Licensed Rights in consideration of Your -acceptance of these terms and conditions, and the Licensor grants You -such rights in consideration of benefits the Licensor receives from -making the Licensed Material available under these terms and -conditions. - - -Section 1 -- Definitions. - - a. Adapted Material means material subject to Copyright and Similar - Rights that is derived from or based upon the Licensed Material - and in which the Licensed Material is translated, altered, - arranged, transformed, or otherwise modified in a manner requiring - permission under the Copyright and Similar Rights held by the - Licensor. For purposes of this Public License, where the Licensed - Material is a musical work, performance, or sound recording, - Adapted Material is always produced where the Licensed Material is - synched in timed relation with a moving image. - - b. Adapter's License means the license You apply to Your Copyright - and Similar Rights in Your contributions to Adapted Material in - accordance with the terms and conditions of this Public License. - - c. Copyright and Similar Rights means copyright and/or similar rights - closely related to copyright including, without limitation, - performance, broadcast, sound recording, and Sui Generis Database - Rights, without regard to how the rights are labeled or - categorized. For purposes of this Public License, the rights - specified in Section 2(b)(1)-(2) are not Copyright and Similar - Rights. - d. Effective Technological Measures means those measures that, in the - absence of proper authority, may not be circumvented under laws - fulfilling obligations under Article 11 of the WIPO Copyright - Treaty adopted on December 20, 1996, and/or similar international - agreements. - - e. Exceptions and Limitations means fair use, fair dealing, and/or - any other exception or limitation to Copyright and Similar Rights - that applies to Your use of the Licensed Material. - - f. Licensed Material means the artistic or literary work, database, - or other material to which the Licensor applied this Public - License. - - g. Licensed Rights means the rights granted to You subject to the - terms and conditions of this Public License, which are limited to - all Copyright and Similar Rights that apply to Your use of the - Licensed Material and that the Licensor has authority to license. - - h. Licensor means the individual(s) or entity(ies) granting rights - under this Public License. - - i. NonCommercial means not primarily intended for or directed towards - commercial advantage or monetary compensation. For purposes of - this Public License, the exchange of the Licensed Material for - other material subject to Copyright and Similar Rights by digital - file-sharing or similar means is NonCommercial provided there is - no payment of monetary compensation in connection with the - exchange. - - j. Share means to provide material to the public by any means or - process that requires permission under the Licensed Rights, such - as reproduction, public display, public performance, distribution, - dissemination, communication, or importation, and to make material - available to the public including in ways that members of the - public may access the material from a place and at a time - individually chosen by them. - - k. Sui Generis Database Rights means rights other than copyright - resulting from Directive 96/9/EC of the European Parliament and of - the Council of 11 March 1996 on the legal protection of databases, - as amended and/or succeeded, as well as other essentially - equivalent rights anywhere in the world. - - l. You means the individual or entity exercising the Licensed Rights - under this Public License. Your has a corresponding meaning. - - -Section 2 -- Scope. - - a. License grant. - - 1. Subject to the terms and conditions of this Public License, - the Licensor hereby grants You a worldwide, royalty-free, - non-sublicensable, non-exclusive, irrevocable license to - exercise the Licensed Rights in the Licensed Material to: - - a. reproduce and Share the Licensed Material, in whole or - in part, for NonCommercial purposes only; and - - b. produce, reproduce, and Share Adapted Material for - NonCommercial purposes only. - - 2. Exceptions and Limitations. For the avoidance of doubt, where - Exceptions and Limitations apply to Your use, this Public - License does not apply, and You do not need to comply with - its terms and conditions. - - 3. Term. The term of this Public License is specified in Section - 6(a). - - 4. Media and formats; technical modifications allowed. The - Licensor authorizes You to exercise the Licensed Rights in - all media and formats whether now known or hereafter created, - and to make technical modifications necessary to do so. The - Licensor waives and/or agrees not to assert any right or - authority to forbid You from making technical modifications - necessary to exercise the Licensed Rights, including - technical modifications necessary to circumvent Effective - Technological Measures. For purposes of this Public License, - simply making modifications authorized by this Section 2(a) - (4) never produces Adapted Material. - - 5. Downstream recipients. - - a. Offer from the Licensor -- Licensed Material. Every - recipient of the Licensed Material automatically - receives an offer from the Licensor to exercise the - Licensed Rights under the terms and conditions of this - Public License. - - b. No downstream restrictions. You may not offer or impose - any additional or different terms or conditions on, or - apply any Effective Technological Measures to, the - Licensed Material if doing so restricts exercise of the - Licensed Rights by any recipient of the Licensed - Material. - - 6. No endorsement. Nothing in this Public License constitutes or - may be construed as permission to assert or imply that You - are, or that Your use of the Licensed Material is, connected - with, or sponsored, endorsed, or granted official status by, - the Licensor or others designated to receive attribution as - provided in Section 3(a)(1)(A)(i). - - b. Other rights. - - 1. Moral rights, such as the right of integrity, are not - licensed under this Public License, nor are publicity, - privacy, and/or other similar personality rights; however, to - the extent possible, the Licensor waives and/or agrees not to - assert any such rights held by the Licensor to the limited - extent necessary to allow You to exercise the Licensed - Rights, but not otherwise. - - 2. Patent and trademark rights are not licensed under this - Public License. - - 3. To the extent possible, the Licensor waives any right to - collect royalties from You for the exercise of the Licensed - Rights, whether directly or through a collecting society - under any voluntary or waivable statutory or compulsory - licensing scheme. In all other cases the Licensor expressly - reserves any right to collect such royalties, including when - the Licensed Material is used other than for NonCommercial - purposes. - - -Section 3 -- License Conditions. - -Your exercise of the Licensed Rights is expressly made subject to the -following conditions. - - a. Attribution. - - 1. If You Share the Licensed Material (including in modified - form), You must: - - a. retain the following if it is supplied by the Licensor - with the Licensed Material: - - i. identification of the creator(s) of the Licensed - Material and any others designated to receive - attribution, in any reasonable manner requested by - the Licensor (including by pseudonym if - designated); - - ii. a copyright notice; - - iii. a notice that refers to this Public License; - - iv. a notice that refers to the disclaimer of - warranties; - - v. a URI or hyperlink to the Licensed Material to the - extent reasonably practicable; - - b. indicate if You modified the Licensed Material and - retain an indication of any previous modifications; and - - c. indicate the Licensed Material is licensed under this - Public License, and include the text of, or the URI or - hyperlink to, this Public License. - - 2. You may satisfy the conditions in Section 3(a)(1) in any - reasonable manner based on the medium, means, and context in - which You Share the Licensed Material. For example, it may be - reasonable to satisfy the conditions by providing a URI or - hyperlink to a resource that includes the required - information. - - 3. If requested by the Licensor, You must remove any of the - information required by Section 3(a)(1)(A) to the extent - reasonably practicable. - - 4. If You Share Adapted Material You produce, the Adapter's - License You apply must not prevent recipients of the Adapted - Material from complying with this Public License. - - -Section 4 -- Sui Generis Database Rights. - -Where the Licensed Rights include Sui Generis Database Rights that -apply to Your use of the Licensed Material: - - a. for the avoidance of doubt, Section 2(a)(1) grants You the right - to extract, reuse, reproduce, and Share all or a substantial - portion of the contents of the database for NonCommercial purposes - only; - - b. if You include all or a substantial portion of the database - contents in a database in which You have Sui Generis Database - Rights, then the database in which You have Sui Generis Database - Rights (but not its individual contents) is Adapted Material; and - - c. You must comply with the conditions in Section 3(a) if You Share - all or a substantial portion of the contents of the database. - -For the avoidance of doubt, this Section 4 supplements and does not -replace Your obligations under this Public License where the Licensed -Rights include other Copyright and Similar Rights. - - -Section 5 -- Disclaimer of Warranties and Limitation of Liability. - - a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE - EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS - AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF - ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS, - IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION, - WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR - PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS, - ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT - KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT - ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU. - - b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE - TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION, - NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT, - INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES, - COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR - USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN - ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR - DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR - IN PART, THIS LIMITATION MAY NOT APPLY TO YOU. - - c. The disclaimer of warranties and limitation of liability provided - above shall be interpreted in a manner that, to the extent - possible, most closely approximates an absolute disclaimer and - waiver of all liability. - - -Section 6 -- Term and Termination. - - a. This Public License applies for the term of the Copyright and - Similar Rights licensed here. However, if You fail to comply with - this Public License, then Your rights under this Public License - terminate automatically. - - b. Where Your right to use the Licensed Material has terminated under - Section 6(a), it reinstates: - - 1. automatically as of the date the violation is cured, provided - it is cured within 30 days of Your discovery of the - violation; or - - 2. upon express reinstatement by the Licensor. - - For the avoidance of doubt, this Section 6(b) does not affect any - right the Licensor may have to seek remedies for Your violations - of this Public License. - - c. For the avoidance of doubt, the Licensor may also offer the - Licensed Material under separate terms or conditions or stop - distributing the Licensed Material at any time; however, doing so - will not terminate this Public License. - - d. Sections 1, 5, 6, 7, and 8 survive termination of this Public - License. - - -Section 7 -- Other Terms and Conditions. - - a. The Licensor shall not be bound by any additional or different - terms or conditions communicated by You unless expressly agreed. - - b. Any arrangements, understandings, or agreements regarding the - Licensed Material not stated herein are separate from and - independent of the terms and conditions of this Public License. - - -Section 8 -- Interpretation. - - a. For the avoidance of doubt, this Public License does not, and - shall not be interpreted to, reduce, limit, restrict, or impose - conditions on any use of the Licensed Material that could lawfully - be made without permission under this Public License. - - b. To the extent possible, if any provision of this Public License is - deemed unenforceable, it shall be automatically reformed to the - minimum extent necessary to make it enforceable. If the provision - cannot be reformed, it shall be severed from this Public License - without affecting the enforceability of the remaining terms and - conditions. - - c. No term or condition of this Public License will be waived and no - failure to comply consented to unless expressly agreed to by the - Licensor. - - d. Nothing in this Public License constitutes or may be interpreted - as a limitation upon, or waiver of, any privileges and immunities - that apply to the Licensor or You, including from the legal - processes of any jurisdiction or authority. - -======================================================================= - -Creative Commons is not a party to its public -licenses. Notwithstanding, Creative Commons may elect to apply one of -its public licenses to material it publishes and in those instances -will be considered the “Licensor.” The text of the Creative Commons -public licenses is dedicated to the public domain under the CC0 Public -Domain Dedication. Except for the limited purpose of indicating that -material is shared under a Creative Commons public license or as -otherwise permitted by the Creative Commons policies published at -creativecommons.org/policies, Creative Commons does not authorize the -use of the trademark "Creative Commons" or any other trademark or logo -of Creative Commons without its prior written consent including, -without limitation, in connection with any unauthorized modifications -to any of its public licenses or any other arrangements, -understandings, or agreements concerning use of licensed material. For -the avoidance of doubt, this paragraph does not form part of the -public licenses. - -Creative Commons may be contacted at creativecommons.org. - +MARK 53 — JARVIS +Copyright (c) 2026 FatihMakes + +Licensed under the Creative Commons Attribution-NonCommercial 4.0 +International License (CC BY-NC 4.0). Commercial use is not permitted. +Canonical text: https://creativecommons.org/licenses/by-nc/4.0/legalcode + +----------------------------------------------------------------------- + +Attribution-NonCommercial 4.0 International + +======================================================================= + +Creative Commons Corporation ("Creative Commons") is not a law firm and +does not provide legal services or legal advice. Distribution of +Creative Commons public licenses does not create a lawyer-client or +other relationship. Creative Commons makes its licenses and related +information available on an "as-is" basis. Creative Commons gives no +warranties regarding its licenses, any material licensed under their +terms and conditions, or any related information. Creative Commons +disclaims all liability for damages resulting from their use to the +fullest extent possible. + +Using Creative Commons Public Licenses + +Creative Commons public licenses provide a standard set of terms and +conditions that creators and other rights holders may use to share +original works of authorship and other material subject to copyright +and certain other rights specified in the public license below. The +following considerations are for informational purposes only, are not +exhaustive, and do not form part of our licenses. + + Considerations for licensors: Our public licenses are + intended for use by those authorized to give the public + permission to use material in ways otherwise restricted by + copyright and certain other rights. Our licenses are + irrevocable. Licensors should read and understand the terms + and conditions of the license they choose before applying it. + Licensors should also secure all rights necessary before + applying our licenses so that the public can reuse the + material as expected. Licensors should clearly mark any + material not subject to the license. This includes other CC- + licensed material, or material used under an exception or + limitation to copyright. More considerations for licensors: + wiki.creativecommons.org/Considerations_for_licensors + + Considerations for the public: By using one of our public + licenses, a licensor grants the public permission to use the + licensed material under specified terms and conditions. If + the licensor's permission is not necessary for any reason--for + example, because of any applicable exception or limitation to + copyright--then that use is not regulated by the license. Our + licenses grant only permissions under copyright and certain + other rights that a licensor has authority to grant. Use of + the licensed material may still be restricted for other + reasons, including because others have copyright or other + rights in the material. A licensor may make special requests, + such as asking that all changes be marked or described. + Although not required by our licenses, you are encouraged to + respect those requests where reasonable. More considerations + for the public: + wiki.creativecommons.org/Considerations_for_licensees + +======================================================================= + +Creative Commons Attribution-NonCommercial 4.0 International Public +License + +By exercising the Licensed Rights (defined below), You accept and agree +to be bound by the terms and conditions of this Creative Commons +Attribution-NonCommercial 4.0 International Public License ("Public +License"). To the extent this Public License may be interpreted as a +contract, You are granted the Licensed Rights in consideration of Your +acceptance of these terms and conditions, and the Licensor grants You +such rights in consideration of benefits the Licensor receives from +making the Licensed Material available under these terms and +conditions. + + +Section 1 -- Definitions. + + a. Adapted Material means material subject to Copyright and Similar + Rights that is derived from or based upon the Licensed Material + and in which the Licensed Material is translated, altered, + arranged, transformed, or otherwise modified in a manner requiring + permission under the Copyright and Similar Rights held by the + Licensor. For purposes of this Public License, where the Licensed + Material is a musical work, performance, or sound recording, + Adapted Material is always produced where the Licensed Material is + synched in timed relation with a moving image. + + b. Adapter's License means the license You apply to Your Copyright + and Similar Rights in Your contributions to Adapted Material in + accordance with the terms and conditions of this Public License. + + c. Copyright and Similar Rights means copyright and/or similar rights + closely related to copyright including, without limitation, + performance, broadcast, sound recording, and Sui Generis Database + Rights, without regard to how the rights are labeled or + categorized. For purposes of this Public License, the rights + specified in Section 2(b)(1)-(2) are not Copyright and Similar + Rights. + d. Effective Technological Measures means those measures that, in the + absence of proper authority, may not be circumvented under laws + fulfilling obligations under Article 11 of the WIPO Copyright + Treaty adopted on December 20, 1996, and/or similar international + agreements. + + e. Exceptions and Limitations means fair use, fair dealing, and/or + any other exception or limitation to Copyright and Similar Rights + that applies to Your use of the Licensed Material. + + f. Licensed Material means the artistic or literary work, database, + or other material to which the Licensor applied this Public + License. + + g. Licensed Rights means the rights granted to You subject to the + terms and conditions of this Public License, which are limited to + all Copyright and Similar Rights that apply to Your use of the + Licensed Material and that the Licensor has authority to license. + + h. Licensor means the individual(s) or entity(ies) granting rights + under this Public License. + + i. NonCommercial means not primarily intended for or directed towards + commercial advantage or monetary compensation. For purposes of + this Public License, the exchange of the Licensed Material for + other material subject to Copyright and Similar Rights by digital + file-sharing or similar means is NonCommercial provided there is + no payment of monetary compensation in connection with the + exchange. + + j. Share means to provide material to the public by any means or + process that requires permission under the Licensed Rights, such + as reproduction, public display, public performance, distribution, + dissemination, communication, or importation, and to make material + available to the public including in ways that members of the + public may access the material from a place and at a time + individually chosen by them. + + k. Sui Generis Database Rights means rights other than copyright + resulting from Directive 96/9/EC of the European Parliament and of + the Council of 11 March 1996 on the legal protection of databases, + as amended and/or succeeded, as well as other essentially + equivalent rights anywhere in the world. + + l. You means the individual or entity exercising the Licensed Rights + under this Public License. Your has a corresponding meaning. + + +Section 2 -- Scope. + + a. License grant. + + 1. Subject to the terms and conditions of this Public License, + the Licensor hereby grants You a worldwide, royalty-free, + non-sublicensable, non-exclusive, irrevocable license to + exercise the Licensed Rights in the Licensed Material to: + + a. reproduce and Share the Licensed Material, in whole or + in part, for NonCommercial purposes only; and + + b. produce, reproduce, and Share Adapted Material for + NonCommercial purposes only. + + 2. Exceptions and Limitations. For the avoidance of doubt, where + Exceptions and Limitations apply to Your use, this Public + License does not apply, and You do not need to comply with + its terms and conditions. + + 3. Term. The term of this Public License is specified in Section + 6(a). + + 4. Media and formats; technical modifications allowed. The + Licensor authorizes You to exercise the Licensed Rights in + all media and formats whether now known or hereafter created, + and to make technical modifications necessary to do so. The + Licensor waives and/or agrees not to assert any right or + authority to forbid You from making technical modifications + necessary to exercise the Licensed Rights, including + technical modifications necessary to circumvent Effective + Technological Measures. For purposes of this Public License, + simply making modifications authorized by this Section 2(a) + (4) never produces Adapted Material. + + 5. Downstream recipients. + + a. Offer from the Licensor -- Licensed Material. Every + recipient of the Licensed Material automatically + receives an offer from the Licensor to exercise the + Licensed Rights under the terms and conditions of this + Public License. + + b. No downstream restrictions. You may not offer or impose + any additional or different terms or conditions on, or + apply any Effective Technological Measures to, the + Licensed Material if doing so restricts exercise of the + Licensed Rights by any recipient of the Licensed + Material. + + 6. No endorsement. Nothing in this Public License constitutes or + may be construed as permission to assert or imply that You + are, or that Your use of the Licensed Material is, connected + with, or sponsored, endorsed, or granted official status by, + the Licensor or others designated to receive attribution as + provided in Section 3(a)(1)(A)(i). + + b. Other rights. + + 1. Moral rights, such as the right of integrity, are not + licensed under this Public License, nor are publicity, + privacy, and/or other similar personality rights; however, to + the extent possible, the Licensor waives and/or agrees not to + assert any such rights held by the Licensor to the limited + extent necessary to allow You to exercise the Licensed + Rights, but not otherwise. + + 2. Patent and trademark rights are not licensed under this + Public License. + + 3. To the extent possible, the Licensor waives any right to + collect royalties from You for the exercise of the Licensed + Rights, whether directly or through a collecting society + under any voluntary or waivable statutory or compulsory + licensing scheme. In all other cases the Licensor expressly + reserves any right to collect such royalties, including when + the Licensed Material is used other than for NonCommercial + purposes. + + +Section 3 -- License Conditions. + +Your exercise of the Licensed Rights is expressly made subject to the +following conditions. + + a. Attribution. + + 1. If You Share the Licensed Material (including in modified + form), You must: + + a. retain the following if it is supplied by the Licensor + with the Licensed Material: + + i. identification of the creator(s) of the Licensed + Material and any others designated to receive + attribution, in any reasonable manner requested by + the Licensor (including by pseudonym if + designated); + + ii. a copyright notice; + + iii. a notice that refers to this Public License; + + iv. a notice that refers to the disclaimer of + warranties; + + v. a URI or hyperlink to the Licensed Material to the + extent reasonably practicable; + + b. indicate if You modified the Licensed Material and + retain an indication of any previous modifications; and + + c. indicate the Licensed Material is licensed under this + Public License, and include the text of, or the URI or + hyperlink to, this Public License. + + 2. You may satisfy the conditions in Section 3(a)(1) in any + reasonable manner based on the medium, means, and context in + which You Share the Licensed Material. For example, it may be + reasonable to satisfy the conditions by providing a URI or + hyperlink to a resource that includes the required + information. + + 3. If requested by the Licensor, You must remove any of the + information required by Section 3(a)(1)(A) to the extent + reasonably practicable. + + 4. If You Share Adapted Material You produce, the Adapter's + License You apply must not prevent recipients of the Adapted + Material from complying with this Public License. + + +Section 4 -- Sui Generis Database Rights. + +Where the Licensed Rights include Sui Generis Database Rights that +apply to Your use of the Licensed Material: + + a. for the avoidance of doubt, Section 2(a)(1) grants You the right + to extract, reuse, reproduce, and Share all or a substantial + portion of the contents of the database for NonCommercial purposes + only; + + b. if You include all or a substantial portion of the database + contents in a database in which You have Sui Generis Database + Rights, then the database in which You have Sui Generis Database + Rights (but not its individual contents) is Adapted Material; and + + c. You must comply with the conditions in Section 3(a) if You Share + all or a substantial portion of the contents of the database. + +For the avoidance of doubt, this Section 4 supplements and does not +replace Your obligations under this Public License where the Licensed +Rights include other Copyright and Similar Rights. + + +Section 5 -- Disclaimer of Warranties and Limitation of Liability. + + a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE + EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS + AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF + ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS, + IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION, + WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR + PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS, + ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT + KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT + ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU. + + b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE + TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION, + NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT, + INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES, + COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR + USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN + ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR + DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR + IN PART, THIS LIMITATION MAY NOT APPLY TO YOU. + + c. The disclaimer of warranties and limitation of liability provided + above shall be interpreted in a manner that, to the extent + possible, most closely approximates an absolute disclaimer and + waiver of all liability. + + +Section 6 -- Term and Termination. + + a. This Public License applies for the term of the Copyright and + Similar Rights licensed here. However, if You fail to comply with + this Public License, then Your rights under this Public License + terminate automatically. + + b. Where Your right to use the Licensed Material has terminated under + Section 6(a), it reinstates: + + 1. automatically as of the date the violation is cured, provided + it is cured within 30 days of Your discovery of the + violation; or + + 2. upon express reinstatement by the Licensor. + + For the avoidance of doubt, this Section 6(b) does not affect any + right the Licensor may have to seek remedies for Your violations + of this Public License. + + c. For the avoidance of doubt, the Licensor may also offer the + Licensed Material under separate terms or conditions or stop + distributing the Licensed Material at any time; however, doing so + will not terminate this Public License. + + d. Sections 1, 5, 6, 7, and 8 survive termination of this Public + License. + + +Section 7 -- Other Terms and Conditions. + + a. The Licensor shall not be bound by any additional or different + terms or conditions communicated by You unless expressly agreed. + + b. Any arrangements, understandings, or agreements regarding the + Licensed Material not stated herein are separate from and + independent of the terms and conditions of this Public License. + + +Section 8 -- Interpretation. + + a. For the avoidance of doubt, this Public License does not, and + shall not be interpreted to, reduce, limit, restrict, or impose + conditions on any use of the Licensed Material that could lawfully + be made without permission under this Public License. + + b. To the extent possible, if any provision of this Public License is + deemed unenforceable, it shall be automatically reformed to the + minimum extent necessary to make it enforceable. If the provision + cannot be reformed, it shall be severed from this Public License + without affecting the enforceability of the remaining terms and + conditions. + + c. No term or condition of this Public License will be waived and no + failure to comply consented to unless expressly agreed to by the + Licensor. + + d. Nothing in this Public License constitutes or may be interpreted + as a limitation upon, or waiver of, any privileges and immunities + that apply to the Licensor or You, including from the legal + processes of any jurisdiction or authority. + +======================================================================= + +Creative Commons is not a party to its public +licenses. Notwithstanding, Creative Commons may elect to apply one of +its public licenses to material it publishes and in those instances +will be considered the “Licensor.” The text of the Creative Commons +public licenses is dedicated to the public domain under the CC0 Public +Domain Dedication. Except for the limited purpose of indicating that +material is shared under a Creative Commons public license or as +otherwise permitted by the Creative Commons policies published at +creativecommons.org/policies, Creative Commons does not authorize the +use of the trademark "Creative Commons" or any other trademark or logo +of Creative Commons without its prior written consent including, +without limitation, in connection with any unauthorized modifications +to any of its public licenses or any other arrangements, +understandings, or agreements concerning use of licensed material. For +the avoidance of doubt, this paragraph does not form part of the +public licenses. + +Creative Commons may be contacted at creativecommons.org. + diff --git a/actions/background_monitor.py b/actions/background_monitor.py index d34a1fd..2461fa9 100644 --- a/actions/background_monitor.py +++ b/actions/background_monitor.py @@ -13,10 +13,10 @@ from pathlib import Path # ── Blocked categories (never monitor regardless of what user says) ──────────── _BLOCKED = { - # Marka / varlık adları — her dilde aynı yazılır + # Brand / asset names — spelled the same in every language "bitcoin", "ethereum", "dogecoin", "solana", "binance", "nft", "blockchain", "defi", "altcoin", "memecoin", "coin", "token", - # "kripto" kökünün farklı dillerdeki yazılışları + # spellings of the "crypto" root across different languages "crypto", "kripto", "cripto", "krypto", "крипто", "仮想通貨", "暗号資産", "cryptocurrency", } diff --git a/actions/browser_control.py b/actions/browser_control.py index 10cb685..3b24817 100644 --- a/actions/browser_control.py +++ b/actions/browser_control.py @@ -369,12 +369,12 @@ _WIN_EXE_HINTS: dict[str, str] = {"chrome": "chrome", "edge": "msedge"} def _open_native(url: str, browser_name: Optional[str]) -> str: """ - Kullanıcının GERÇEK tarayıcısını normal şekilde açar — kendi profili, - giriş yapılmış hesapları ve eklentileriyle. Otomasyon bağlanmaz, bu yüzden - about:blank sekmesi veya boş profil ASLA görünmez. - url boş ise tarayıcı URL'siz başlatılır (kendi açılış sayfası / - oturum geri yükleme ile) — tıpkı kullanıcının kendisi açmış gibi. - Windows / macOS / Linux üçünde de çalışır. + Opens the user's REAL browser normally — with their own profile, + logged-in accounts and extensions. No automation attaches, so an + about:blank tab or a blank profile NEVER shows up. + If url is empty the browser starts with no URL (its own start page / + session restore) — exactly as if the user had opened it themselves. + Works on all three of Windows / macOS / Linux. """ url = _normalize_url(url) if url and url.strip() else "" if url == "about:blank": @@ -384,7 +384,7 @@ def _open_native(url: str, browser_name: Optional[str]) -> str: if browser_name: name = _ALIASES.get(browser_name.lower().strip(), browser_name.lower().strip()) elif not url: - # URL yok → sadece pencere açılacak; varsayılan tarayıcının exe'si gerekir + # No URL → only a window will open; needs the default browser's exe name = _detect_default_browser() # Specific browser → launch its own executable, exactly like the user would. @@ -443,8 +443,8 @@ def _open_native(url: str, browser_name: Optional[str]) -> str: class _BrowserSession: """ - Bir tarayıcı örneği için tam oturum. - Tüm tarayıcılar launch_persistent_context ile gerçek profil üzerinde açılır. + A full session for one browser instance. + All browsers open on the real profile via launch_persistent_context. """ def __init__(self, browser_name: str): @@ -505,9 +505,9 @@ class _BrowserSession: async def _adopt_page(self) -> Page: """ - launch_persistent_context zaten bir başlangıç sekmesi açar. - Yeni bir boş sekme (about:blank) açmak yerine o sekmeyi devralır — - böylece kullanıcı fazladan boş sekme görmez. + launch_persistent_context already opens a starting tab. + Instead of opening a new blank tab (about:blank), it adopts that tab — + so the user never sees an extra blank tab. """ await asyncio.sleep(0.3) pages = self._context.pages @@ -515,8 +515,8 @@ class _BrowserSession: async def _launch(self): """ - Tarayıcıyı gerçek kullanıcı profiliyle başlatır. - Context zaten açıksa hiçbir şey yapmaz. + Launches the browser with the real user profile. + Does nothing if the context is already open. """ if self._context is not None: return @@ -607,10 +607,10 @@ class _BrowserSession: except Exception as e: print(f"[Browser] ⚠️ Real profile failed for {label}: {e}") - # Gerçek profil açılamadı (tarayıcı zaten açık / kilitli profil / yeni - # Chrome sürümleri otomasyonla gerçek profili engelliyor). Kalıcı - # JARVIS otomasyon profiline geçilir — buraya bir kez giriş yapılan - # hesaplar sonraki oturumlarda da açık kalır. + # The real profile could not be opened (browser already open / locked + # profile / newer Chrome versions block the real profile under + # automation). Fall back to a persistent JARVIS automation profile — + # accounts logged in here once stay logged in on later sessions too. jarvis_profile = str(Path.home() / ".jarvis_profiles" / self.browser_name) Path(jarvis_profile).mkdir(parents=True, exist_ok=True) print(f"[Browser] Retrying with JARVIS profile: {jarvis_profile}") @@ -840,7 +840,7 @@ class _BrowserSession: return f"{self.browser_name} closed." class _SessionRegistry: - """Tüm aktif tarayıcı oturumlarını yönetir.""" + """Manages all active browser sessions.""" def __init__(self): self._sessions: dict[str, _BrowserSession] = {} @@ -849,7 +849,7 @@ class _SessionRegistry: self._last_native_url: str = "" def has(self, browser_name: str | None = None) -> bool: - """Bu tarayıcı için (veya hiç) aktif bir otomasyon oturumu var mı?""" + """Is there an active automation session for this browser (or any)?""" with self._lock: if not browser_name: return bool(self._sessions) @@ -860,7 +860,7 @@ class _SessionRegistry: self._last_native_url = url def pop_native_url(self) -> str: - """Son native açılan URL'yi bir kez döndürür (tekrarı önlemek için tüketilir).""" + """Returns the last natively-opened URL once (consumed to avoid repeats).""" url, self._last_native_url = self._last_native_url, "" return url @@ -956,12 +956,12 @@ def browser_control( _log(player, result) return result - # ── Gezinme HER ZAMAN native ───────────────────────────────────────────── - # go_to / search / new_tab siteyi kullanıcının kendi tarayıcısında açar — - # kendi profili, giriş yapılmış hesapları ve açılış sayfasıyla; tıpkı - # kullanıcının kendisi açmış gibi. about:blank'li kontrollü pencere burada - # asla açılmaz. Tek istisna: hâlihazırda süren bir otomasyon akışı varsa - # gezinme o pencerede devam eder (çok adımlı görevler bölünmesin diye). + # ── Navigation is ALWAYS native ────────────────────────────────────────── + # go_to / search / new_tab open the site in the user's own browser — + # their own profile, logged-in accounts and start page; exactly as if the + # user had opened it themselves. A controlled window with about:blank never + # opens here. The only exception: if an automation flow is already running, + # navigation continues in that window (so multi-step tasks aren't split). if action in ("go_to", "search", "new_tab"): if _registry.has(browser): sess = _registry.get(browser) @@ -993,10 +993,10 @@ def browser_control( _log(player, result) return result - # ── Etkileşimli aksiyonlar (tıklama/yazma/okuma…) ──────────────────────── - # Bunlar fiziksel olarak kontrol edilebilir bir tarayıcı gerektirir; - # yalnızca burada otomasyon penceresi açılır ve açılır açılmaz kullanıcının - # son gezindiği sayfaya gider — boş sayfada beklemez. + # ── Interactive actions (click/type/read…) ─────────────────────────────── + # These require a physically controllable browser; the automation window + # only opens here, and as soon as it opens it goes to the user's last + # navigated page — it doesn't sit on a blank page. try: sess = _registry.get(browser) except Exception as e: @@ -1057,4 +1057,76 @@ def _log(player, text: str): short = str(text)[:80] print(f"[Browser] {short}") if player: - player.write_log(f"[browser] {short[:60]}") \ No newline at end of file + player.write_log(f"[browser] {short[:60]}") + + +# ── Tool declaration (auto-discovered by core/action_loader.py) ────────────── +TOOL = { + "name": "browser_control", + "description": "Controls any web browser. Use for: opening websites, searching the web, clicking elements, filling forms, scrolling, screenshots, navigation, any web-based task. Simple open/search requests launch the user's own browser normally (their real profile and logged-in accounts); interactive actions (click, type, fill_form...) attach an automation browser. Always pass the 'browser' parameter when the user specifies a browser (e.g. 'open in Edge', 'use Firefox', 'open Chrome'). Multiple browsers can run simultaneously.", + "parameters": { + "type": "OBJECT", + "properties": { + "action": { + "type": "STRING", + "description": "go_to | search | click | type | scroll | fill_form | smart_click | smart_type | get_text | get_url | press | new_tab | close_tab | screenshot | back | forward | reload | switch | list_browsers | close | close_all" + }, + "browser": { + "type": "STRING", + "description": "Target browser: chrome | edge | firefox | opera | operagx | brave | vivaldi | safari. Omit to use the currently active browser." + }, + "url": { + "type": "STRING", + "description": "URL for go_to / new_tab action" + }, + "query": { + "type": "STRING", + "description": "Search query for search action" + }, + "engine": { + "type": "STRING", + "description": "Search engine: google | bing | duckduckgo | yandex (default: google)" + }, + "selector": { + "type": "STRING", + "description": "CSS selector for click/type" + }, + "text": { + "type": "STRING", + "description": "Text to click or type" + }, + "description": { + "type": "STRING", + "description": "Element description for smart_click/smart_type" + }, + "direction": { + "type": "STRING", + "description": "up | down for scroll" + }, + "amount": { + "type": "INTEGER", + "description": "Scroll amount in pixels (default: 500)" + }, + "key": { + "type": "STRING", + "description": "Key name for press action (e.g. Enter, Escape, F5)" + }, + "path": { + "type": "STRING", + "description": "Save path for screenshot" + }, + "incognito": { + "type": "BOOLEAN", + "description": "Open in private/incognito mode" + }, + "clear_first": { + "type": "BOOLEAN", + "description": "Clear field before typing (default: true)" + } + }, + "required": [ + "action" + ] + }, + "handler": browser_control, +} diff --git a/actions/code_helper.py b/actions/code_helper.py index 21db5e6..7a0e952 100644 --- a/actions/code_helper.py +++ b/actions/code_helper.py @@ -115,10 +115,10 @@ _VALID_INTENTS = {"write", "edit", "explain", "run", "build", "screen_debug", "o def _detect_intent(description: str, file_path: str, code: str) -> str: """ - Dil bağımsız niyet tespiti — sabit anahtar kelime listesi YOK. - Kullanıcı hangi dilde konuşursa konuşsun, açıklama Gemini'ye - sınıflandırtılır. API'ye ulaşılamazsa dile bakmayan yapısal - ipuçlarına (dosya diskte var mı, kod verilmiş mi) düşülür. + Language-independent intent detection — NO fixed keyword list. + Whatever language the user speaks, the description is classified by + Gemini. If the API is unreachable, it falls back to language-agnostic + structural hints (does the file exist on disk, was code provided). """ desc = (description or "").strip() file_exists = bool(file_path) and Path(file_path).exists() @@ -152,7 +152,7 @@ def _detect_intent(description: str, file_path: str, code: str) -> str: except Exception as e: print(f"[Code] Intent classification failed ({e}) — structural fallback") - # Yapısal geri dönüş — hiçbir dile bağlı değil + # Structural fallback — not tied to any language if file_exists: return "edit" if desc else "explain" if code: @@ -581,4 +581,52 @@ def code_helper( return _screen_debug_action(description, file_path, player, speak) else: - return f"Unknown action: '{action}'. Use write, edit, explain, run, build, optimize, or screen_debug." \ No newline at end of file + return f"Unknown action: '{action}'. Use write, edit, explain, run, build, optimize, or screen_debug." + + +# ── Tool declaration (auto-discovered by core/action_loader.py) ────────────── +TOOL = { + "name": "code_helper", + "description": "Writes, edits, explains, runs, or builds code files.", + "parameters": { + "type": "OBJECT", + "properties": { + "action": { + "type": "STRING", + "description": "write | edit | explain | run | build | auto (default: auto)" + }, + "description": { + "type": "STRING", + "description": "What the code should do or what change to make" + }, + "language": { + "type": "STRING", + "description": "Programming language (default: python)" + }, + "output_path": { + "type": "STRING", + "description": "Where to save the file" + }, + "file_path": { + "type": "STRING", + "description": "Path to existing file for edit/explain/run/build" + }, + "code": { + "type": "STRING", + "description": "Raw code string for explain" + }, + "args": { + "type": "STRING", + "description": "CLI arguments for run/build" + }, + "timeout": { + "type": "INTEGER", + "description": "Execution timeout in seconds (default: 30)" + } + }, + "required": [ + "action" + ] + }, + "handler": code_helper, +} diff --git a/actions/computer_control.py b/actions/computer_control.py index e19b29e..a9f4822 100644 --- a/actions/computer_control.py +++ b/actions/computer_control.py @@ -511,4 +511,80 @@ def computer_control( except Exception as e: print(f"[ComputerControl] ❌ {action}: {e}") - return f"computer_control '{action}' failed: {e}" \ No newline at end of file + return f"computer_control '{action}' failed: {e}" + + +# ── Tool declaration (auto-discovered by core/action_loader.py) ────────────── +TOOL = { + "name": "computer_control", + "description": "Direct computer control: type, click, hotkeys, scroll, move mouse, screenshots, find elements on screen.", + "parameters": { + "type": "OBJECT", + "properties": { + "action": { + "type": "STRING", + "description": "type | smart_type | click | double_click | right_click | hotkey | press | scroll | move | copy | paste | screenshot | wait | clear_field | focus_window | screen_find | screen_click | random_data | user_data" + }, + "text": { + "type": "STRING", + "description": "Text to type or paste" + }, + "x": { + "type": "INTEGER", + "description": "X coordinate" + }, + "y": { + "type": "INTEGER", + "description": "Y coordinate" + }, + "keys": { + "type": "STRING", + "description": "Key combination e.g. 'ctrl+c'" + }, + "key": { + "type": "STRING", + "description": "Single key e.g. 'enter'" + }, + "direction": { + "type": "STRING", + "description": "up | down | left | right" + }, + "amount": { + "type": "INTEGER", + "description": "Scroll amount (default: 3)" + }, + "seconds": { + "type": "NUMBER", + "description": "Seconds to wait" + }, + "title": { + "type": "STRING", + "description": "Window title for focus_window" + }, + "description": { + "type": "STRING", + "description": "Element description for screen_find/screen_click" + }, + "type": { + "type": "STRING", + "description": "Data type for random_data" + }, + "field": { + "type": "STRING", + "description": "Field for user_data: name|email|city" + }, + "clear_first": { + "type": "BOOLEAN", + "description": "Clear field before typing (default: true)" + }, + "path": { + "type": "STRING", + "description": "Save path for screenshot" + } + }, + "required": [ + "action" + ] + }, + "handler": computer_control, +} diff --git a/actions/computer_settings.py b/actions/computer_settings.py index 6895454..3167c48 100644 --- a/actions/computer_settings.py +++ b/actions/computer_settings.py @@ -905,4 +905,58 @@ def computer_settings( push_undo("dark mode toggled", lambda: (dark_mode(), "Theme switched back.")[1]) - return f"Done: {action}." \ No newline at end of file + return f"Done: {action}." + + +# ── Tool declaration (auto-discovered by core/action_loader.py) ────────────── +TOOL = { + "name": "computer_settings", + "description": "Controls the computer: volume, brightness, window management, keyboard shortcuts, typing text on screen, closing apps, fullscreen, dark mode, WiFi, restart, shutdown, scrolling, tab management, zoom, screenshots, lock screen, refresh/reload page. Use for ANY single computer control command. restart, shutdown and toggle_wifi put a confirmation on the user's screen and do NOT happen until they press it — never claim they are done. Volume, brightness and dark mode can be reversed with the `undo` tool.", + "parameters": { + "type": "OBJECT", + "properties": { + # The exact vocabulary, spelled out. + # + # This used to say only "The action to perform", so the model + # usually filled `description` instead — and computer_settings then + # made a SECOND Gemini call, inside the tool, purely to translate + # that sentence into one of these names. Every "turn the volume + # down" cost two model round trips. + # + # Naming the actions here costs ~600 characters once per session and + # removes a whole round trip from every computer command. + "action": { + "type": "STRING", + "description": ( + "The exact action. Prefer this over `description` — pick one of: " + "volume_up | volume_down | volume_set | mute | " + "brightness_up | brightness_down | sleep_display | " + "pause_video | close_app | close_window | full_screen | " + "minimize | maximize | snap_left | snap_right | " + "switch_window | show_desktop | task_manager | focus_search | " + "refresh_page | close_tab | new_tab | next_tab | prev_tab | " + "go_back | go_forward | zoom_in | zoom_out | zoom_reset | " + "find_on_page | scroll_up | scroll_down | scroll_top | " + "scroll_bottom | page_up | page_down | copy | paste | cut | " + "undo | redo | select_all | save | enter | escape | press_key | " + "type_text | screenshot | lock_screen | open_settings | " + "file_explorer | open_run | dark_mode | toggle_wifi | " + "restart | shutdown" + ) + }, + "description": { + "type": "STRING", + "description": ( + "Fallback only, when no action name above fits. " + "Resolved locally — no extra model call." + ) + }, + "value": { + "type": "STRING", + "description": "Optional value: volume level 0-100, text to type, key name, etc." + } + }, + "required": [] + }, + "handler": computer_settings, +} diff --git a/actions/desktop.py b/actions/desktop.py index 9fc1b81..d687462 100644 --- a/actions/desktop.py +++ b/actions/desktop.py @@ -477,4 +477,40 @@ def desktop_control( except Exception as e: print(f"[Desktop] Error: {e}") - return f"Desktop control error: {e}" \ No newline at end of file + return f"Desktop control error: {e}" + + +# ── Tool declaration (auto-discovered by core/action_loader.py) ────────────── +TOOL = { + "name": "desktop_control", + "description": "Controls the desktop: wallpaper, organize, clean, list, stats.", + "parameters": { + "type": "OBJECT", + "properties": { + "action": { + "type": "STRING", + "description": "wallpaper | wallpaper_url | organize | clean | list | stats | task" + }, + "path": { + "type": "STRING", + "description": "Image path for wallpaper" + }, + "url": { + "type": "STRING", + "description": "Image URL for wallpaper_url" + }, + "mode": { + "type": "STRING", + "description": "by_type or by_date for organize" + }, + "task": { + "type": "STRING", + "description": "Natural language desktop task" + } + }, + "required": [ + "action" + ] + }, + "handler": desktop_control, +} diff --git a/actions/dev_agent.py b/actions/dev_agent.py index 6b6df0e..be59cbe 100644 --- a/actions/dev_agent.py +++ b/actions/dev_agent.py @@ -324,7 +324,7 @@ def _run_project(run_command: str, project_dir: Path, timeout: int = 30) -> str: return f"Run error: {e}" def _try_auto_install(error_output: str, project_dir: Path) -> bool: - """ModuleNotFoundError varsa eksik paketi otomatik kurmaya çalışır.""" + """If there is a ModuleNotFoundError, tries to auto-install the missing package.""" pattern = re.compile( r"No module named ['\"]([a-zA-Z0-9_\-\.]+)['\"]", re.IGNORECASE ) @@ -599,4 +599,36 @@ def dev_agent( timeout = timeout, speak = speak, player = player, - ) \ No newline at end of file + ) + + +# ── Tool declaration (auto-discovered by core/action_loader.py) ────────────── +TOOL = { + "name": "dev_agent", + "description": "Builds complete multi-file projects from scratch: plans, writes files, installs deps, opens VSCode, runs and fixes errors.", + "parameters": { + "type": "OBJECT", + "properties": { + "description": { + "type": "STRING", + "description": "What the project should do" + }, + "language": { + "type": "STRING", + "description": "Programming language (default: python)" + }, + "project_name": { + "type": "STRING", + "description": "Optional project folder name" + }, + "timeout": { + "type": "INTEGER", + "description": "Run timeout in seconds (default: 30)" + } + }, + "required": [ + "description" + ] + }, + "handler": dev_agent, +} diff --git a/actions/file_controller.py b/actions/file_controller.py index ae5d663..24c20d5 100644 --- a/actions/file_controller.py +++ b/actions/file_controller.py @@ -95,7 +95,7 @@ _SAFE_ROOTS: list[Path] = [ ] def _is_safe_path(target: Path) -> bool: - """Verilen path _SAFE_ROOTS içinde mi? Değilse işlemi reddet.""" + """Is the given path inside _SAFE_ROOTS? If not, reject the operation.""" try: resolved = target.resolve() return any( @@ -158,22 +158,9 @@ def _resolve_path(raw: str) -> Path: "videos": _get_videos(), "home": Path.home(), } - raw = raw.strip().strip('"').strip("'") - lower = raw.lower() + lower = raw.strip().lower() if lower in shortcuts: return shortcuts[lower] - - # "desktop/notes/a.md" and "desktop\notes\a.md" — a shortcut followed by a - # sub-path. Without this branch the whole string falls through to the - # relative-path return below and is resolved against the process CWD instead - # of the real Desktop: an "Access denied" when the project lives outside the - # home directory, or — worse — a silent write into a stray "desktop" folder - # inside the project when it lives inside it. - head, sep, rest = raw.replace("\\", "/").partition("/") - if sep and head.lower() in shortcuts: - rest = rest.strip("/") - return shortcuts[head.lower()] / rest if rest else shortcuts[head.lower()] - return Path(raw).expanduser() def _format_size(b: int) -> str: @@ -275,7 +262,7 @@ def delete_file(path: str, name: str = "") -> str: if not target.exists(): return f"Not found: {target.name}" - # Güvenli dizin kontrolü — kritik kullanıcı klasörlerini koru + # Safe-directory check — protect critical user folders protected = { _get_desktop(), _get_downloads(), _get_documents(), _get_pictures(), _get_music(), _get_videos(), Path.home() @@ -461,7 +448,7 @@ def find_files(name: str = "", extension: str = "", results = [] dir_count = 0 - max_dirs = 500 # performans + güvenlik limiti + max_dirs = 500 # performance + safety limit for item in search_path.rglob("*"): if item.is_dir(): @@ -556,7 +543,7 @@ def organize_desktop() -> str: try: for item in desktop.iterdir(): - # Klasörlere, gizli dosyalara ve organize klasörlerine dokunma + # Leave folders, hidden files and organize-folders untouched if item.is_dir() or item.name.startswith("."): continue if item.name in {k for k in type_map}: @@ -718,4 +705,52 @@ def file_controller( return f"Unknown action: '{action}'" except Exception as e: - return f"File controller error ({action}): {e}" \ No newline at end of file + return f"File controller error ({action}): {e}" + + +# ── Tool declaration (auto-discovered by core/action_loader.py) ────────────── +TOOL = { + "name": "file_controller", + "description": "Manages files and folders: list, create, delete, move, copy, rename, read, write, find, disk usage.", + "parameters": { + "type": "OBJECT", + "properties": { + "action": { + "type": "STRING", + "description": "list | create_file | create_folder | delete | move | copy | rename | read | write | find | largest | disk_usage | organize_desktop | info" + }, + "path": { + "type": "STRING", + "description": "File/folder path or shortcut: desktop, downloads, documents, home" + }, + "destination": { + "type": "STRING", + "description": "Destination path for move/copy" + }, + "new_name": { + "type": "STRING", + "description": "New name for rename" + }, + "content": { + "type": "STRING", + "description": "Content for create_file/write" + }, + "name": { + "type": "STRING", + "description": "File name to search for" + }, + "extension": { + "type": "STRING", + "description": "File extension to search (e.g. .pdf)" + }, + "count": { + "type": "INTEGER", + "description": "Number of results for largest" + } + }, + "required": [ + "action" + ] + }, + "handler": file_controller, +} diff --git a/actions/file_processor.py b/actions/file_processor.py index 10fb8d4..9965c9d 100644 --- a/actions/file_processor.py +++ b/actions/file_processor.py @@ -833,4 +833,86 @@ def file_processor(parameters: dict, player=None, speak=None) -> str: except Exception as e: import traceback traceback.print_exc() - return f"Processing failed: {e}" \ No newline at end of file + return f"Processing failed: {e}" + + +# ── Tool declaration (auto-discovered by core/action_loader.py) ────────────── +TOOL = { + "name": "file_processor", + "description": "Processes any file that the user has uploaded or dropped onto the interface. Use this when the user refers to an uploaded file and wants an action on it. Supports: images (describe/ocr/resize/compress/convert), PDFs (summarize/extract_text/to_word), Word docs & text files (summarize/fix/reformat/translate), CSV/Excel (analyze/stats/filter/sort/convert), JSON/XML (validate/format/analyze), code files (explain/review/fix/optimize/run/document/test), audio (transcribe/trim/convert/info), video (trim/extract_audio/extract_frame/compress/transcribe/info), archives (list/extract), presentations (summarize/extract_text). ALWAYS call this tool when a file has been uploaded and the user gives a command about it. If the user's command is ambiguous, pick the most logical action for that file type.", + "parameters": { + "type": "OBJECT", + "properties": { + "file_path": { + "type": "STRING", + "description": "Full path to the uploaded file. Leave empty to use the currently uploaded file." + }, + "action": { + "type": "STRING", + "description": "What to do with the file. Examples by type:\nimage: describe | ocr | resize | compress | convert | info\npdf: summarize | extract_text | to_word | info\ndocx/txt: summarize | fix | reformat | translate_hint | word_count | to_bullet\ncsv/excel: analyze | stats | filter | sort | convert | info\njson: validate | format | analyze | to_csv\ncode: explain | review | fix | optimize | run | document | test\naudio: transcribe | trim | convert | info\nvideo: trim | extract_audio | extract_frame | compress | transcribe | info | convert\narchive: list | extract\npptx: summarize | extract_text | analyze" + }, + "instruction": { + "type": "STRING", + "description": "Free-form instruction if action doesn't cover it. E.g. 'translate this to Turkish', 'find all email addresses'" + }, + "format": { + "type": "STRING", + "description": "Target format for conversion. E.g. 'mp3', 'pdf', 'csv', 'png'" + }, + "width": { + "type": "INTEGER", + "description": "Target width for image resize" + }, + "height": { + "type": "INTEGER", + "description": "Target height for image resize" + }, + "scale": { + "type": "NUMBER", + "description": "Scale factor for image resize (e.g. 0.5)" + }, + "quality": { + "type": "INTEGER", + "description": "Quality 1-100 for image/video compress" + }, + "start": { + "type": "STRING", + "description": "Start time for trim: seconds or HH:MM:SS" + }, + "end": { + "type": "STRING", + "description": "End time for trim: seconds or HH:MM:SS" + }, + "timestamp": { + "type": "STRING", + "description": "Timestamp for video frame extraction HH:MM:SS" + }, + "column": { + "type": "STRING", + "description": "Column name for CSV filter/sort" + }, + "value": { + "type": "STRING", + "description": "Filter value for CSV filter" + }, + "condition": { + "type": "STRING", + "description": "Filter condition: equals|contains|gt|lt" + }, + "ascending": { + "type": "BOOLEAN", + "description": "Sort order for CSV sort (default: true)" + }, + "save": { + "type": "BOOLEAN", + "description": "Save result to file (default: true)" + }, + "destination": { + "type": "STRING", + "description": "Output folder for archive extract" + } + }, + "required": [] + }, + "handler": file_processor, +} diff --git a/actions/flight_finder.py b/actions/flight_finder.py index 906ce57..1484ac5 100644 --- a/actions/flight_finder.py +++ b/actions/flight_finder.py @@ -27,14 +27,13 @@ _MONTH_MAP: dict[str, int] = { "january": 1, "february": 2, "march": 3, "april": 4, "may": 5, "june": 6, "july": 7, "august": 8, "september": 9, "october": 10, "november": 11, "december": 12, - "ocak": 1, "şubat": 2, "mart": 3, "nisan": 4, - "mayıs": 5, "haziran": 6, "temmuz": 7, "ağustos": 8, - "eylül": 9, "ekim": 10, "kasım": 11, "aralık": 12, } +# English fast-path only — Gemini (below) normalizes date expressions in ANY +# language to YYYY-MM-DD, so no other language needs to be hardcoded here. _RELATIVE_MAP_KEYS = { - "today", "bugün", - "tomorrow", "yarın", + "today", + "tomorrow", } @@ -53,9 +52,8 @@ def _parse_date(raw: str) -> str: pass relative = { - "today": today, "bugün": today, + "today": today, "tomorrow": today + timedelta(days=1), - "yarın": today + timedelta(days=1), } for key, val in relative.items(): if key in lower: @@ -361,4 +359,50 @@ def flight_finder(parameters: dict, player=None, speak=None) -> str: except Exception as e: print(f"[FlightFinder] ❌ {e}") - return f"Flight search failed, sir: {e}" \ No newline at end of file + return f"Flight search failed, sir: {e}" + + +# ── Tool declaration (auto-discovered by core/action_loader.py) ────────────── +TOOL = { + "name": "flight_finder", + "description": "Searches Google Flights and speaks the best options.", + "parameters": { + "type": "OBJECT", + "properties": { + "origin": { + "type": "STRING", + "description": "Departure city or airport code" + }, + "destination": { + "type": "STRING", + "description": "Arrival city or airport code" + }, + "date": { + "type": "STRING", + "description": "Departure date (any format)" + }, + "return_date": { + "type": "STRING", + "description": "Return date for round trips" + }, + "passengers": { + "type": "INTEGER", + "description": "Number of passengers (default: 1)" + }, + "cabin": { + "type": "STRING", + "description": "economy | premium | business | first" + }, + "save": { + "type": "BOOLEAN", + "description": "Save results to Notepad" + } + }, + "required": [ + "origin", + "destination", + "date" + ] + }, + "handler": flight_finder, +} diff --git a/actions/game_updater.py b/actions/game_updater.py index 65e2e1d..003dabe 100644 --- a/actions/game_updater.py +++ b/actions/game_updater.py @@ -202,7 +202,7 @@ def _click_first_profile_by_screenshot() -> bool: time.sleep(1.5) win = _get_steam_window_rect() if not win: - print("[GameUpdater] ⚠️ Steam penceresi bulunamadı") + print("[GameUpdater] ⚠️ Steam window not found") return False wx, wy, ww, wh = win @@ -220,7 +220,7 @@ def _click_first_profile_by_screenshot() -> bool: colorful = (max_c > 60) & ((max_c - min_c) > 40) if not colorful.any(): - print("[GameUpdater] ⚠️ Avatar rengi bulunamadı — tahminle tıklanıyor") + print("[GameUpdater] ⚠️ Avatar colour not found — clicking by guess") pyautogui.click(wx + ww // 2 - ww // 6, wy + wh // 2) return True @@ -235,20 +235,20 @@ def _click_first_profile_by_screenshot() -> bool: abs_x = wx + search_x1 + int(block_cols.mean()) abs_y = wy + search_y1 + int(rows.mean()) - print(f"[GameUpdater] 🎯 Profil avatarı ({abs_x}, {abs_y}) — tıklanıyor") + print(f"[GameUpdater] 🎯 Profile avatar ({abs_x}, {abs_y}) — clicking") pyautogui.click(abs_x, abs_y) return True except ImportError as e: - print(f"[GameUpdater] ⚠️ Eksik kütüphane: {e}") + print(f"[GameUpdater] ⚠️ Missing library: {e}") return False except Exception as e: - print(f"[GameUpdater] ⚠️ Profil tespiti başarısız: {e}") + print(f"[GameUpdater] ⚠️ Profile detection failed: {e}") return False def _handle_steam_profile_selection() -> bool: - print("[GameUpdater] 🔍 Profil seçim dialogu kontrol ediliyor...") + print("[GameUpdater] 🔍 Checking profile-selection dialog...") win = _get_steam_window_rect() if not win: return False @@ -266,14 +266,14 @@ def _handle_steam_profile_selection() -> bool: (top_region[:,:,2] > 200) )) if not is_small and white_pixels <= 100: - print("[GameUpdater] ℹ️ Profil dialogu yok — Steam zaten giriş yapmış") + print("[GameUpdater] ℹ️ No profile dialog — Steam is already logged in") return False except ImportError: pass except Exception: pass - print("[GameUpdater] 👤 Profil seçimi tespit edildi — ilk profile tıklanıyor") + print("[GameUpdater] 👤 Profile selection detected — clicking the first profile") return _click_first_profile_by_screenshot() def _find_best_drive() -> dict | None: @@ -298,7 +298,7 @@ def _select_drive_in_dialog(dialog, drive_letter: str) -> bool: for ctrl in dialog.descendants(control_type=control_type): if target in ctrl.window_text().upper(): ctrl.click_input() - print(f"[GameUpdater] ✅ Sürücü seçildi ({control_type}): {ctrl.window_text()}") + print(f"[GameUpdater] ✅ Drive selected ({control_type}): {ctrl.window_text()}") return True except Exception: continue @@ -390,12 +390,17 @@ def _handle_install_dialog(game_name: str) -> str: drive_letter = best_drive["letter"] drive_label = f"{drive_letter}:" - print(f"[GameUpdater] 🏆 Hedef sürücü: {drive_label} ({best_drive['free_gb']:.1f} GB boş)") + print(f"[GameUpdater] 🏆 Target drive: {drive_label} ({best_drive['free_gb']:.1f} GB free)") try: from pywinauto import Application, findwindows dialog = None + # NOTE: this drives Steam's native installer dialog by matching its + # on-screen button/title text, which is localized. We match English and + # Turkish labels (install/yükle, next/ileri, ok/tamam). On a Steam client + # set to another language the match simply fails and the automation + # reports it — it never crashes, and manual install still works. for _ in range(40): time.sleep(0.5) try: @@ -423,7 +428,7 @@ def _handle_install_dialog(game_name: str) -> str: break if not dialog: - raise RuntimeError("Dialog bulunamadı") + raise RuntimeError("Dialog not found") dialog.set_focus() time.sleep(0.4) @@ -440,7 +445,7 @@ def _handle_install_dialog(game_name: str) -> str: except ImportError: return _handle_install_dialog_pyautogui(game_name, best_drive) except Exception as e: - print(f"[GameUpdater] ⚠️ pywinauto başarısız: {e}") + print(f"[GameUpdater] ⚠️ pywinauto failed: {e}") return _handle_install_dialog_pyautogui(game_name, best_drive) def _ensure_steam_running(steam_path: Path) -> bool: @@ -449,10 +454,10 @@ def _ensure_steam_running(steam_path: Path) -> bool: exe = _steam_exe(steam_path) if not exe.exists(): - print(f"[GameUpdater] ❌ Steam bulunamadı: {exe}") + print(f"[GameUpdater] ❌ Steam not found: {exe}") return False - print("[GameUpdater] 🚀 Steam başlatılıyor...") + print("[GameUpdater] 🚀 Starting Steam...") if is_mac(): subprocess.Popen(["open", "-a", "Steam"]) else: @@ -461,14 +466,14 @@ def _ensure_steam_running(steam_path: Path) -> bool: for _ in range(20): time.sleep(1) if _is_steam_running(): - print("[GameUpdater] ✅ Steam çalışıyor") + print("[GameUpdater] ✅ Steam is running") time.sleep(4) if is_windows(): _handle_steam_profile_selection() time.sleep(2) return True - print("[GameUpdater] ⚠️ Steam başlatılamadı") + print("[GameUpdater] ⚠️ Could not start Steam") return False def _search_steam_appid(game_name: str) -> tuple[str | None, str | None]: @@ -488,7 +493,7 @@ def _search_steam_appid(game_name: str) -> tuple[str | None, str | None]: for key, (app_id, canonical) in _KNOWN_APPIDS.items(): if name_lower in key or key in name_lower: - print(f"[GameUpdater] 📖 Kısmi eşleşme: {canonical} ({app_id})") + print(f"[GameUpdater] 📖 Partial match: {canonical} ({app_id})") return app_id, canonical try: @@ -503,7 +508,7 @@ def _search_steam_appid(game_name: str) -> tuple[str | None, str | None]: print(f"[GameUpdater] 🌐 Store API: {best['name']} ({best['id']})") return str(best["id"]), best["name"] except Exception as e: - print(f"[GameUpdater] ⚠️ AppID arama başarısız: {e}") + print(f"[GameUpdater] ⚠️ AppID lookup failed: {e}") return None, None @@ -1051,4 +1056,46 @@ if __name__ == "__main__": if "--scheduled" in sys.argv: print(f"[GameUpdater] 🕐 Scheduled run at {datetime.now().strftime('%H:%M')}") result = game_updater({"action": "update", "platform": "both"}) - print(f"[GameUpdater] ✅ {result}") \ No newline at end of file + print(f"[GameUpdater] ✅ {result}") + + +# ── Tool declaration (auto-discovered by core/action_loader.py) ────────────── +TOOL = { + "name": "game_updater", + "description": "THE ONLY tool for ANY Steam or Epic Games request. Use for: installing, downloading, updating games, listing installed games, checking download status, scheduling updates. ALWAYS call directly for any Steam/Epic/game request. NEVER use browser_control or web_search for Steam/Epic.", + "parameters": { + "type": "OBJECT", + "properties": { + "action": { + "type": "STRING", + "description": "update | install | list | download_status | schedule | cancel_schedule | schedule_status (default: update)" + }, + "platform": { + "type": "STRING", + "description": "steam | epic | both (default: both)" + }, + "game_name": { + "type": "STRING", + "description": "Game name (partial match supported)" + }, + "app_id": { + "type": "STRING", + "description": "Steam AppID for install (optional)" + }, + "hour": { + "type": "INTEGER", + "description": "Hour for scheduled update 0-23 (default: 3)" + }, + "minute": { + "type": "INTEGER", + "description": "Minute for scheduled update 0-59 (default: 0)" + }, + "shutdown_when_done": { + "type": "BOOLEAN", + "description": "Shut down PC when download finishes" + } + }, + "required": [] + }, + "handler": game_updater, +} diff --git a/actions/open_app.py b/actions/open_app.py index 5e49f82..4aa0b36 100644 --- a/actions/open_app.py +++ b/actions/open_app.py @@ -270,4 +270,24 @@ def open_app( ) except Exception as e: print(f"[open_app] Error: {e}") - return f"Failed to open {app_name}: {e}" \ No newline at end of file + return f"Failed to open {app_name}: {e}" + + +# ── Tool declaration (auto-discovered by core/action_loader.py) ────────────── +TOOL = { + "name": "open_app", + "description": "Opens any application on the computer. Use this whenever the user asks to open, launch, or start any app, website, or program. Always call this tool — never just say you opened it.", + "parameters": { + "type": "OBJECT", + "properties": { + "app_name": { + "type": "STRING", + "description": "Exact name of the application (e.g. 'WhatsApp', 'Chrome', 'Spotify')" + } + }, + "required": [ + "app_name" + ] + }, + "handler": open_app, +} diff --git a/actions/reminder.py b/actions/reminder.py index 6bc7dd3..23707ec 100644 --- a/actions/reminder.py +++ b/actions/reminder.py @@ -334,4 +334,34 @@ def reminder( player.write_log(f"[Reminder] ✅ {date_str} {time_str} — {safe_msg[:40]}") friendly_time = target_dt.strftime("%B %d at %I:%M %p") - return f"Reminder set for {friendly_time}." \ No newline at end of file + return f"Reminder set for {friendly_time}." + + +# ── Tool declaration (auto-discovered by core/action_loader.py) ────────────── +TOOL = { + "name": "reminder", + "description": "Sets a timed reminder using Task Scheduler.", + "parameters": { + "type": "OBJECT", + "properties": { + "date": { + "type": "STRING", + "description": "Date in YYYY-MM-DD format" + }, + "time": { + "type": "STRING", + "description": "Time in HH:MM format (24h)" + }, + "message": { + "type": "STRING", + "description": "Reminder message text" + } + }, + "required": [ + "date", + "time", + "message" + ] + }, + "handler": reminder, +} diff --git a/actions/screen_processor.py b/actions/screen_processor.py index 9f6efad..ef0a68b 100644 --- a/actions/screen_processor.py +++ b/actions/screen_processor.py @@ -1,464 +1,183 @@ -from __future__ import annotations - -import asyncio -import base64 -import io -import json -import re -import sys -import threading -import time -from pathlib import Path -from typing import Optional - -import numpy as np -import sounddevice as sd - -try: - import cv2 - _CV2 = True -except ImportError: - _CV2 = False - -try: - import mss - import mss.tools - _MSS = True -except ImportError: - _MSS = False - -try: - import PIL.Image - _PIL = True -except ImportError: - _PIL = False - -from google import genai -from google.genai import types as gtypes - -def _base_dir() -> Path: - if getattr(sys, "frozen", False): - return Path(sys.executable).parent - return Path(__file__).resolve().parent.parent - - -_BASE = _base_dir() -_CONFIG_PATH = _BASE / "config" / "api_keys.json" - - -def _load_config() -> dict: - try: - return json.loads(_CONFIG_PATH.read_text(encoding="utf-8")) - except Exception: - return {} - - -def _save_config_key(key: str, value) -> None: - try: - cfg = _load_config() - cfg[key] = value - _CONFIG_PATH.write_text(json.dumps(cfg, indent=4), encoding="utf-8") - except Exception as e: - print(f"[Vision] ⚠️ Could not save config key '{key}': {e}") - - -def _get_api_key() -> str: - key = _load_config().get("gemini_api_key", "") - if not key: - raise RuntimeError("gemini_api_key not found in config.") - return key - - -def _get_os() -> str: - return _load_config().get("os_system", "windows").lower() - -_LIVE_MODEL = "models/gemini-2.5-flash-native-audio-preview-12-2025" -_CHANNELS = 1 -_RECEIVE_SAMPLE_RATE = 24_000 -_CHUNK_SIZE = 1_024 - -_IMG_MAX_W = 1280 -_IMG_MAX_H = 720 -_JPEG_Q = 82 - -_SYSTEM_PROMPT = ( - "You are JARVIS, Tony Stark's AI assistant. " - "You are given an image from either the user's screen or their webcam. " - "Analyze what you see with detail and intelligence. " - "Describe objects, text, people, components, and their context clearly. " - "For technical questions (circuits, code, hardware) give specific, expert answers. " - "Be concise — 2-4 sentences — unless the question demands more detail. " - "Speak directly to the user ('I can see...', 'You have...'). " - "Address the user as 'sir' depending on the language they used." -) - - -def _compress(img_bytes: bytes, source_format: str = "PNG") -> tuple[bytes, str]: - if not _PIL: - return img_bytes, f"image/{source_format.lower()}" - - try: - img = PIL.Image.open(io.BytesIO(img_bytes)).convert("RGB") - img.thumbnail((_IMG_MAX_W, _IMG_MAX_H), PIL.Image.BILINEAR) - buf = io.BytesIO() - img.save(buf, format="JPEG", quality=_JPEG_Q, optimize=False) - return buf.getvalue(), "image/jpeg" - except Exception as e: - print(f"[Vision] ⚠️ Image compress failed: {e}") - return img_bytes, f"image/{source_format.lower()}" - -def _capture_screen() -> tuple[bytes, str]: - - if not _MSS: - raise RuntimeError("mss is not installed. Run: pip install mss") - - with mss.mss() as sct: - monitors = sct.monitors # [0] = all combined, [1..n] = real screens - target = monitors[1] if len(monitors) > 1 else monitors[0] - shot = sct.grab(target) - png = mss.tools.to_png(shot.rgb, shot.size) - - return _compress(png, "PNG") - - -def _cv2_backend() -> int: - """Return the best OpenCV camera backend for the current OS.""" - if not _CV2: - return 0 - os_name = _get_os() - if os_name == "windows": - return cv2.CAP_DSHOW - if os_name == "mac": - return cv2.CAP_AVFOUNDATION - return cv2.CAP_ANY - - -def _probe_camera(index: int, backend: int, warmup: int = 5) -> bool: - - if not _CV2: - return False - cap = cv2.VideoCapture(index, backend) - if not cap.isOpened(): - cap.release() - return False - for _ in range(warmup): - cap.read() - ret, frame = cap.read() - cap.release() - if not ret or frame is None: - return False - return bool(np.mean(frame) > 8) - - -def _detect_camera_index() -> int: - - backend = _cv2_backend() - print("[Vision] 🔍 Auto-detecting camera...") - for idx in range(6): - if _probe_camera(idx, backend): - print(f"[Vision] ✅ Camera found at index {idx}") - _save_config_key("camera_index", idx) - return idx - print(f"[Vision] ⚠️ Camera index {idx}: no usable frame") - - print("[Vision] ⚠️ No camera found — defaulting to index 0") - _save_config_key("camera_index", 0) - return 0 - - -def _get_camera_index() -> int: - cfg = _load_config() - if "camera_index" in cfg: - return int(cfg["camera_index"]) - return _detect_camera_index() - - -def _capture_camera() -> tuple[bytes, str]: - if not _CV2: - raise RuntimeError("OpenCV (cv2) is not installed. Run: pip install opencv-python") - - index = _get_camera_index() - backend = _cv2_backend() - cap = cv2.VideoCapture(index, backend) - - if not cap.isOpened(): - raise RuntimeError(f"Camera index {index} could not be opened.") - - for _ in range(10): - cap.read() - - ret, frame = cap.read() - cap.release() - - if not ret or frame is None: - raise RuntimeError("Camera returned no frame.") - - if _PIL: - rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) - img = PIL.Image.fromarray(rgb) - img.thumbnail((_IMG_MAX_W, _IMG_MAX_H), PIL.Image.BILINEAR) - buf = io.BytesIO() - img.save(buf, format="JPEG", quality=_JPEG_Q) - return buf.getvalue(), "image/jpeg" - - _, buf = cv2.imencode(".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, _JPEG_Q]) - return buf.tobytes(), "image/jpeg" - -class _VisionSession: - def __init__(self): - self._loop: Optional[asyncio.AbstractEventLoop] = None - self._thread: Optional[threading.Thread] = None - self._session = None - self._out_queue: Optional[asyncio.Queue] = None - self._audio_in: Optional[asyncio.Queue] = None - self._ready_evt: threading.Event = threading.Event() - self._player = None - self._lock: threading.Lock = threading.Lock() - - def start(self, player=None, timeout: float = 25.0) -> None: - with self._lock: - if self._thread and self._thread.is_alive(): - if player is not None: - self._player = player - return - self._player = player - self._thread = threading.Thread( - target=self._run_event_loop, - daemon=True, - name="VisionSessionThread", - ) - self._thread.start() - - if not self._ready_evt.wait(timeout=timeout): - raise RuntimeError(f"Vision session did not connect within {timeout}s.") - print("[Vision] ✅ Session ready") - - def analyze(self, image_bytes: bytes, mime_type: str, user_text: str) -> None: - if not self._loop or not self._out_queue: - print("[Vision] ⚠️ Session not started — dropping request") - return - asyncio.run_coroutine_threadsafe( - self._out_queue.put((image_bytes, mime_type, user_text)), - self._loop, - ) - - def is_ready(self) -> bool: - return self._session is not None - - def _run_event_loop(self) -> None: - self._loop = asyncio.new_event_loop() - asyncio.set_event_loop(self._loop) - self._loop.run_until_complete(self._session_loop()) - - async def _session_loop(self) -> None: - self._out_queue = asyncio.Queue(maxsize=30) - self._audio_in = asyncio.Queue() - - client = genai.Client( - api_key=_get_api_key(), - http_options={"api_version": "v1beta"}, - ) - config = gtypes.LiveConnectConfig( - response_modalities=["AUDIO"], - output_audio_transcription={}, - system_instruction=_SYSTEM_PROMPT, - speech_config=gtypes.SpeechConfig( - voice_config=gtypes.VoiceConfig( - prebuilt_voice_config=gtypes.PrebuiltVoiceConfig( - voice_name="Charon" - ) - ) - ), - ) - - backoff = 2.0 - while True: - try: - print("[Vision] 🔌 Connecting...") - async with client.aio.live.connect( - model=_LIVE_MODEL, config=config - ) as session: - self._session = session - self._ready_evt.set() - backoff = 2.0 - print("[Vision] ✅ Connected") - - async with asyncio.TaskGroup() as tg: - tg.create_task(self._send_loop()) - tg.create_task(self._recv_loop()) - tg.create_task(self._play_loop()) - - except* Exception as eg: - for exc in eg.exceptions: - print(f"[Vision] ⚠️ Session error: {exc}") - finally: - self._session = None - self._ready_evt.clear() - - print(f"[Vision] 🔄 Reconnecting in {backoff:.0f}s...") - await asyncio.sleep(backoff) - backoff = min(backoff * 1.5, 30.0) - self._ready_evt.set() - - async def _send_loop(self) -> None: - while True: - image_bytes, mime_type, user_text = await self._out_queue.get() - if not self._session: - print("[Vision] ⚠️ No session — dropping image") - continue - try: - b64 = base64.b64encode(image_bytes).decode("ascii") - await self._session.send_client_content( - turns={ - "parts": [ - {"inline_data": {"mime_type": mime_type, "data": b64}}, - {"text": user_text}, - ] - }, - turn_complete=True, - ) - print(f"[Vision] 📤 Sent {len(image_bytes):,} bytes — '{user_text[:60]}'") - except Exception as e: - print(f"[Vision] ⚠️ Send error: {e}") - raise # propagate to TaskGroup → triggers session reconnect - - async def _recv_loop(self) -> None: - transcript: list[str] = [] - try: - async for response in self._session.receive(): - if response.data: - await self._audio_in.put(response.data) - - sc = response.server_content - if not sc: - continue - - if sc.output_transcription and sc.output_transcription.text: - chunk = sc.output_transcription.text.strip() - if chunk: - transcript.append(chunk) - - if sc.turn_complete: - if transcript and self._player: - full = re.sub(r"\s+", " ", " ".join(transcript)).strip() - if full: - self._player.write_log(f"Jarvis: {full}") - print(f"[Vision] 💬 {full}") - transcript = [] - # Auto-close camera ~2s after JARVIS finishes speaking - if self._player and hasattr(self._player, "stop_camera_stream"): - async def _deferred_close(): - await asyncio.sleep(2.0) - try: - self._player.stop_camera_stream() - except Exception: - pass - asyncio.create_task(_deferred_close()) - - except Exception as e: - print(f"[Vision] ⚠️ Recv error: {e}") - raise - - async def _play_loop(self) -> None: - stream = sd.RawOutputStream( - samplerate=_RECEIVE_SAMPLE_RATE, - channels=_CHANNELS, - dtype="int16", - blocksize=_CHUNK_SIZE, - ) - stream.start() - try: - while True: - chunk = await self._audio_in.get() - await asyncio.to_thread(stream.write, chunk) - except Exception as e: - print(f"[Vision] ❌ Play error: {e}") - raise - finally: - stream.stop() - stream.close() - -_session = _VisionSession() -_session_lock = threading.Lock() -_session_up = False - - -def _ensure_session(player=None) -> None: - global _session_up - with _session_lock: - if not _session_up: - _session.start(player=player) - _session_up = True - elif player is not None: - _session._player = player - - -def screen_process( - parameters: dict, - response=None, - player=None, - session_memory=None, -) -> bool: - - params = parameters or {} - user_text = (params.get("text") or params.get("user_text") or "").strip() - angle = params.get("angle", "screen").lower().strip() - - if not user_text: - print("[Vision] ⚠️ No question provided — aborting") - return False - - print(f"[Vision] ▶ angle={angle!r} question='{user_text[:80]}'") - - try: - _ensure_session(player=player) - except Exception as e: - print(f"[Vision] ❌ Could not start session: {e}") - return False - - try: - if angle == "camera": - image_bytes, mime_type = _capture_camera() - print(f"[Vision] 📷 Camera: {len(image_bytes):,} bytes") - if player and hasattr(player, "start_camera_stream"): - try: - player.start_camera_stream() - except Exception as _e: - print(f"[Vision] ⚠️ Camera stream failed: {_e}") - elif player and hasattr(player, "show_camera_frame"): - try: - player.show_camera_frame(image_bytes) - except Exception as _e: - print(f"[Vision] ⚠️ Camera preview failed: {_e}") - else: - image_bytes, mime_type = _capture_screen() - print(f"[Vision] 🖥️ Screen: {len(image_bytes):,} bytes") - except Exception as e: - print(f"[Vision] ❌ Capture error: {e}") - return False - - _session.analyze(image_bytes, mime_type, user_text) - return True - - -def warmup_session(player=None) -> None: - try: - _ensure_session(player=player) - except Exception as e: - print(f"[Vision] ⚠️ Warmup failed: {e}") - -if __name__ == "__main__": - print("[TEST] screen_processor.py") - print("=" * 52) - mode = input("angle — screen / camera (default: screen): ").strip().lower() or "screen" - q = input("Question (Enter = default): ").strip() or "What do you see? Be brief." - - t0 = time.perf_counter() - warmup_session() - print(f"Session ready in {time.perf_counter()-t0:.2f}s\n") - - t1 = time.perf_counter() - ok = screen_process({"angle": mode, "text": q}) - print(f"Queued in {time.perf_counter()-t1:.3f}s — waiting for audio...") - time.sleep(10) - print("Done." if ok else "Failed.") \ No newline at end of file +""" +Screen & webcam capture for JARVIS vision. + +Provides the two capture entry points main.py uses — `_capture_screen()` and +`_capture_camera()` — plus their helpers (compression, camera auto-detection, +config access). main.py grabs a frame here on demand, then injects it into the +main Gemini Live session; there is no separate vision session here. +""" +from __future__ import annotations + +import io +import json +import sys +from pathlib import Path + +import numpy as np + +try: + import cv2 + _CV2 = True +except ImportError: + _CV2 = False + +try: + import mss + import mss.tools + _MSS = True +except ImportError: + _MSS = False + +try: + import PIL.Image + _PIL = True +except ImportError: + _PIL = False + + +def _base_dir() -> Path: + if getattr(sys, "frozen", False): + return Path(sys.executable).parent + return Path(__file__).resolve().parent.parent + + +_BASE = _base_dir() +_CONFIG_PATH = _BASE / "config" / "api_keys.json" + + +def _load_config() -> dict: + try: + return json.loads(_CONFIG_PATH.read_text(encoding="utf-8")) + except Exception: + return {} + + +def _save_config_key(key: str, value) -> None: + try: + cfg = _load_config() + cfg[key] = value + _CONFIG_PATH.write_text(json.dumps(cfg, indent=4), encoding="utf-8") + except Exception as e: + print(f"[Vision] ⚠️ Could not save config key '{key}': {e}") + + +def _get_os() -> str: + return _load_config().get("os_system", "windows").lower() + + +_IMG_MAX_W = 1280 +_IMG_MAX_H = 720 +_JPEG_Q = 82 + + +def _compress(img_bytes: bytes, source_format: str = "PNG") -> tuple[bytes, str]: + if not _PIL: + return img_bytes, f"image/{source_format.lower()}" + + try: + img = PIL.Image.open(io.BytesIO(img_bytes)).convert("RGB") + img.thumbnail((_IMG_MAX_W, _IMG_MAX_H), PIL.Image.BILINEAR) + buf = io.BytesIO() + img.save(buf, format="JPEG", quality=_JPEG_Q, optimize=False) + return buf.getvalue(), "image/jpeg" + except Exception as e: + print(f"[Vision] ⚠️ Image compress failed: {e}") + return img_bytes, f"image/{source_format.lower()}" + + +def _capture_screen() -> tuple[bytes, str]: + + if not _MSS: + raise RuntimeError("mss is not installed. Run: pip install mss") + + with mss.mss() as sct: + monitors = sct.monitors # [0] = all combined, [1..n] = real screens + target = monitors[1] if len(monitors) > 1 else monitors[0] + shot = sct.grab(target) + png = mss.tools.to_png(shot.rgb, shot.size) + + return _compress(png, "PNG") + + +def _cv2_backend() -> int: + """Return the best OpenCV camera backend for the current OS.""" + if not _CV2: + return 0 + os_name = _get_os() + if os_name == "windows": + return cv2.CAP_DSHOW + if os_name == "mac": + return cv2.CAP_AVFOUNDATION + return cv2.CAP_ANY + + +def _probe_camera(index: int, backend: int, warmup: int = 5) -> bool: + + if not _CV2: + return False + cap = cv2.VideoCapture(index, backend) + if not cap.isOpened(): + cap.release() + return False + for _ in range(warmup): + cap.read() + ret, frame = cap.read() + cap.release() + if not ret or frame is None: + return False + return bool(np.mean(frame) > 8) + + +def _detect_camera_index() -> int: + + backend = _cv2_backend() + print("[Vision] 🔍 Auto-detecting camera...") + for idx in range(6): + if _probe_camera(idx, backend): + print(f"[Vision] ✅ Camera found at index {idx}") + _save_config_key("camera_index", idx) + return idx + print(f"[Vision] ⚠️ Camera index {idx}: no usable frame") + + print("[Vision] ⚠️ No camera found — defaulting to index 0") + _save_config_key("camera_index", 0) + return 0 + + +def _get_camera_index() -> int: + cfg = _load_config() + if "camera_index" in cfg: + return int(cfg["camera_index"]) + return _detect_camera_index() + + +def _capture_camera() -> tuple[bytes, str]: + if not _CV2: + raise RuntimeError("OpenCV (cv2) is not installed. Run: pip install opencv-python") + + index = _get_camera_index() + backend = _cv2_backend() + cap = cv2.VideoCapture(index, backend) + + if not cap.isOpened(): + raise RuntimeError(f"Camera index {index} could not be opened.") + + for _ in range(10): + cap.read() + + ret, frame = cap.read() + cap.release() + + if not ret or frame is None: + raise RuntimeError("Camera returned no frame.") + + if _PIL: + rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) + img = PIL.Image.fromarray(rgb) + img.thumbnail((_IMG_MAX_W, _IMG_MAX_H), PIL.Image.BILINEAR) + buf = io.BytesIO() + img.save(buf, format="JPEG", quality=_JPEG_Q) + return buf.getvalue(), "image/jpeg" + + _, buf = cv2.imencode(".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, _JPEG_Q]) + return buf.tobytes(), "image/jpeg" diff --git a/actions/send_message.py b/actions/send_message.py index ddb163d..4d5bfb3 100644 --- a/actions/send_message.py +++ b/actions/send_message.py @@ -263,4 +263,34 @@ def send_message( if player: player.write_log(f"[msg] {result}") - return result \ No newline at end of file + return result + + +# ── Tool declaration (auto-discovered by core/action_loader.py) ────────────── +TOOL = { + "name": "send_message", + "description": "Sends a text message via WhatsApp, Telegram, or other messaging platform.", + "parameters": { + "type": "OBJECT", + "properties": { + "receiver": { + "type": "STRING", + "description": "Recipient contact name" + }, + "message_text": { + "type": "STRING", + "description": "The message to send" + }, + "platform": { + "type": "STRING", + "description": "Platform: WhatsApp, Telegram, etc." + } + }, + "required": [ + "receiver", + "message_text", + "platform" + ] + }, + "handler": send_message, +} diff --git a/actions/weather_report.py b/actions/weather_report.py index 71ca3ed..e1d7b15 100644 --- a/actions/weather_report.py +++ b/actions/weather_report.py @@ -48,4 +48,24 @@ def _log(message: str, player=None) -> None: try: player.write_log(f"JARVIS: {message}") except Exception: - pass \ No newline at end of file + pass + + +# ── Tool declaration (auto-discovered by core/action_loader.py) ────────────── +TOOL = { + "name": "weather_report", + "description": "Gives the weather report to user", + "parameters": { + "type": "OBJECT", + "properties": { + "city": { + "type": "STRING", + "description": "City name" + } + }, + "required": [ + "city" + ] + }, + "handler": weather_action, +} diff --git a/actions/web_search.py b/actions/web_search.py index 3e98ec8..92cee47 100644 --- a/actions/web_search.py +++ b/actions/web_search.py @@ -1,68 +1,8 @@ #web_search.py import json import sys -import threading -import time from pathlib import Path -# ── Gemini grounding quota circuit breaker ──────────────────────────────────── -# The google_search grounding tool has its own small quota, separate from plain -# generation. Once it is spent every call returns 429 — so retrying it at the -# top of every search only adds a dead round-trip before the DDG fallback runs. -# After a quota error, skip Gemini entirely for a cooldown period. -_QUOTA_COOLDOWN_SEC = 900 # 15 minutes -_quota_blocked_until = 0.0 -_quota_lock = threading.Lock() - - -def _gemini_available() -> bool: - with _quota_lock: - return time.monotonic() >= _quota_blocked_until - - -def _note_gemini_error(exc: Exception) -> None: - """Trip the breaker when the error is a quota / rate-limit rejection.""" - global _quota_blocked_until - msg = str(exc) - if "429" in msg or "RESOURCE_EXHAUSTED" in msg: - with _quota_lock: - already = time.monotonic() < _quota_blocked_until - _quota_blocked_until = time.monotonic() + _QUOTA_COOLDOWN_SEC - if not already: - print( - "[WebSearch] Gemini grounding quota exhausted — skipping it for " - f"{_QUOTA_COOLDOWN_SEC // 60} min and serving results from DDG." - ) - - -class _QuotaCooldown(RuntimeError): - """Raised instead of calling Gemini while the quota breaker is open.""" - - -def _log_gemini_failure(context: str, exc: Exception) -> None: - """Log a Gemini failure — silently when it is just the expected cooldown.""" - if isinstance(exc, _QuotaCooldown): - return # announced once when the breaker tripped; not a warning - print(f"[WebSearch] \u26a0\ufe0f {context} failed ({exc}) — using DDG instead") - - -def _run_bounded(fn, timeout: float, label: str = "task"): - """Run fn() in a daemon thread; return its result, or None if it overruns.""" - box = [None] - - def _run(): - try: - box[0] = fn() - except Exception as e: - _log_gemini_failure(label, e) - - t = threading.Thread(target=_run, daemon=True) - t.start() - t.join(timeout) - if t.is_alive(): - print(f"[WebSearch] {label} exceeded {timeout:.0f}s — moving on") - return box[0] - def _get_base_dir() -> Path: if getattr(sys, "frozen", False): return Path(sys.executable).parent @@ -79,21 +19,14 @@ def _get_api_key() -> str: def _gemini_search(query: str) -> str: - if not _gemini_available(): - raise _QuotaCooldown("Gemini grounding is in quota cooldown") - from google import genai - client = genai.Client(api_key=_get_api_key()) - try: - response = client.models.generate_content( - model="gemini-flash-latest", - contents=query, - config={"tools": [{"google_search": {}}]}, - ) - except Exception as e: - _note_gemini_error(e) - raise + client = genai.Client(api_key=_get_api_key()) + response = client.models.generate_content( + model="gemini-flash-latest", + contents=query, + config={"tools": [{"google_search": {}}]}, + ) text = "" for part in response.candidates[0].content.parts: @@ -106,45 +39,30 @@ def _gemini_search(query: str) -> str: return text -def _get_ddgs(): - """ - Returns the DDGS class. The package was renamed duckduckgo-search -> ddgs; - the legacy package's endpoints are now rejected by DuckDuckGo (news() gets a - 403 Ratelimit, text() silently returns zero results), so warn loudly if we - end up on it instead of failing in silence. - """ +def _ddg_search(query: str, max_results: int = 6) -> list[dict]: try: from ddgs import DDGS - return DDGS except ImportError: from duckduckgo_search import DDGS - print( - "[WebSearch] ⚠️ Using the deprecated 'duckduckgo-search' package — " - "DuckDuckGo blocks its endpoints, so every search will come back " - "empty. Fix with: pip install -U ddgs" - ) - return DDGS - -def _ddg_search(query: str, max_results: int = 6) -> list[dict]: - DDGS = _get_ddgs() results = [] - try: - with DDGS() as ddgs: - for r in ddgs.text(query, max_results=max_results): - results.append({ - "title": r.get("title", ""), - "snippet": r.get("body", ""), - "url": r.get("href", ""), - }) - except Exception as e: - print(f"[WebSearch] ⚠️ DDG text() failed: {e}") + with DDGS() as ddgs: + for r in ddgs.text(query, max_results=max_results): + results.append({ + "title": r.get("title", ""), + "snippet": r.get("body", ""), + "url": r.get("href", ""), + }) return results def _ddg_news(query: str, max_results: int = 8) -> list[dict]: """DDG news search — returns actual articles, not website homepages.""" - DDGS = _get_ddgs() + try: + from ddgs import DDGS + except ImportError: + from duckduckgo_search import DDGS + results = [] try: with DDGS() as ddgs: @@ -157,9 +75,6 @@ def _ddg_news(query: str, max_results: int = 8) -> list[dict]: }) except Exception as e: print(f"[WebSearch] ⚠️ DDG news() failed ({e}) — falling back to text search") - # Also covers the legacy-package case, where news() returns an empty list - # instead of raising. - if not results: results = _ddg_search(query, max_results=max_results) return results @@ -242,43 +157,58 @@ def _search(query: str) -> str: try: return _gemini_search(query) except Exception as e: - _log_gemini_failure("Gemini search", e) + print(f"[WebSearch] ⚠️ Gemini failed ({e}) — trying DDG...") results = _ddg_search(query) return _format_ddg(query, results) def _news(query: str) -> str: """ - DDG first, Gemini as backup. - - The old version raced both backends in parallel and kept the first answer. - That burned one google_search grounding call on *every* news request — - including the startup briefing — even when DDG had already won the race. - Grounding has a small quota, so it ran dry after a handful of launches and - then 429'd for everything else (research/compare), which are the modes that - actually need a synthesised answer. - - DDG news returns in well under a second and gives raw headlines, which is - exactly what the briefing wants, so it goes first and Gemini is only touched - when DDG comes back empty. + Runs Gemini grounded search AND DDG news in parallel. + Returns whichever delivers a valid result first; cancels the other. """ + import threading + gemini_query = f"latest news today: {query}" if query else "top world news today" ddg_query = query if query else "world news today" - def _ddg_attempt() -> str: - return _format_news(ddg_query, _ddg_news(ddg_query, max_results=8)) + result_box = [None] # first valid result lands here + lock = threading.Lock() + done_evt = threading.Event() + failures = [0] - text = _run_bounded(_ddg_attempt, timeout=5.0, label="DDG news") - if text and len(text) > 60 and not text.startswith("No news found"): - return text + def _store(r: str) -> None: + if r and len(r) > 60: + with lock: + if result_box[0] is None: + result_box[0] = r + done_evt.set() + else: + with lock: + failures[0] += 1 + if failures[0] >= 2: # both failed — unblock caller + done_evt.set() - text = _run_bounded( - lambda: _gemini_search(gemini_query), timeout=6.0, label="Gemini news" - ) - if text and len(text) > 60: - return text + def _try_gemini(): + try: + _store(_gemini_search(gemini_query)) + except Exception as e: + print(f"[WebSearch] ⚠️ Gemini news failed ({e})") + _store("") - return f"No news found for: {query}" + def _try_ddg(): + try: + results = _ddg_news(ddg_query, max_results=8) + _store(_format_news(ddg_query, results)) + except Exception as e: + print(f"[WebSearch] ⚠️ DDG news failed ({e})") + _store("") + + threading.Thread(target=_try_gemini, daemon=True).start() + threading.Thread(target=_try_ddg, daemon=True).start() + + done_evt.wait(timeout=10.0) + return result_box[0] or f"No news found for: {query}" def _research(query: str) -> str: @@ -293,7 +223,7 @@ def _research(query: str) -> str: try: return _gemini_search(research_query) except Exception as e: - _log_gemini_failure("Gemini research", e) + print(f"[WebSearch] ⚠️ Research Gemini failed ({e}) — DDG fallback...") results = _ddg_search(query, max_results=10) return _format_ddg(query, results) @@ -304,7 +234,7 @@ def _price(query: str) -> str: try: return _gemini_search(price_query) except Exception as e: - _log_gemini_failure("Gemini price", e) + print(f"[WebSearch] ⚠️ Price Gemini failed ({e}) — DDG fallback...") results = _ddg_search(f"{query} price buy", max_results=6) return _format_ddg(query, results) @@ -317,7 +247,7 @@ def _compare(items: list[str], aspect: str) -> str: try: return _gemini_search(query) except Exception as e: - _log_gemini_failure("Gemini compare", e) + print(f"[WebSearch] ⚠️ Gemini compare failed: {e} — falling back to DDG") all_results: dict[str, list] = {} for item in items: @@ -376,3 +306,38 @@ def web_search( except Exception as e: print(f"[WebSearch] ❌ All backends failed: {e}") return f"Search failed: {e}" + + +# ── Tool declaration (auto-discovered by core/action_loader.py) ────────────── +TOOL = { + "name": "web_search", + "description": "Searches the web. Use for ANY question about current facts, events, prices, or topics — always prefer this over guessing. Modes: 'search' (default), 'news' (latest headlines on a topic), 'research' (deep comprehensive answer), 'price' (product cost lookup), 'compare' (side-by-side comparison of items).", + "parameters": { + "type": "OBJECT", + "properties": { + "query": { + "type": "STRING", + "description": "Search query or topic" + }, + "mode": { + "type": "STRING", + "description": "search | news | research | price | compare" + }, + "items": { + "type": "ARRAY", + "items": { + "type": "STRING" + }, + "description": "Items to compare (compare mode)" + }, + "aspect": { + "type": "STRING", + "description": "Comparison aspect: price | specs | reviews | features" + } + }, + "required": [ + "query" + ] + }, + "handler": web_search, +} diff --git a/actions/youtube_video.py b/actions/youtube_video.py index 8289706..77f0195 100644 --- a/actions/youtube_video.py +++ b/actions/youtube_video.py @@ -434,4 +434,38 @@ def youtube_video( return handler(params, player, speak) or "Done." except Exception as e: print(f"[YouTube] ❌ Error in {action}: {e}") - return f"YouTube {action} failed, sir: {e}" \ No newline at end of file + return f"YouTube {action} failed, sir: {e}" + + +# ── Tool declaration (auto-discovered by core/action_loader.py) ────────────── +TOOL = { + "name": "youtube_video", + "description": "Controls YouTube. Use for: playing videos, summarizing a video's content, getting video info, or showing trending videos.", + "parameters": { + "type": "OBJECT", + "properties": { + "action": { + "type": "STRING", + "description": "play | summarize | get_info | trending (default: play)" + }, + "query": { + "type": "STRING", + "description": "Search query for play action" + }, + "save": { + "type": "BOOLEAN", + "description": "Save summary to Notepad (summarize only)" + }, + "region": { + "type": "STRING", + "description": "Country code for trending e.g. TR, US" + }, + "url": { + "type": "STRING", + "description": "Video URL for get_info action" + } + }, + "required": [] + }, + "handler": youtube_video, +} diff --git a/core/action_loader.py b/core/action_loader.py new file mode 100644 index 0000000..f81975a --- /dev/null +++ b/core/action_loader.py @@ -0,0 +1,193 @@ +""" +Action discovery, validation, and dispatch — the built-in twin of plugin_loader. + +Every actions/*.py that exposes a module-level ``TOOL`` dict is auto-discovered +here, exactly like a drop-in plugin, so main.py never has to hardcode a tool +declaration or a dispatch branch for it. Adding a new bundled action is then the +same one-file operation as writing a plugin: define ``TOOL`` and a handler. + +``TOOL`` shape (see actions/open_app.py for a live example): + + TOOL = { + "name": "open_app", # unique, ^[a-zA-Z_][a-zA-Z0-9_]{0,63}$ + "description": "...", # what Gemini reads to route the call + "parameters": {"type": "OBJECT", ...}, # Gemini function-declaration schema + "handler": open_app, # the callable to run + } + +The handler is invoked through signature introspection: it receives ``parameters`` +plus whichever of ``player`` / ``speak`` / ``response`` / ``session_memory`` it +actually declares — so existing action signatures work unchanged. + +Discovery runs once at startup; import errors, validation errors, and name +collisions are logged and the offending file is skipped — they NEVER raise out +of discover_actions() and never abort the scan of the remaining files. +""" +from __future__ import annotations + +import importlib.util +import inspect +import re +import sys +import traceback +from dataclasses import dataclass, field +from pathlib import Path +from typing import Callable, Optional + +_NAME_RE = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]{0,63}$") +_DEFAULT_PARAMS = {"type": "OBJECT", "properties": {}} +_CTX_KEYS = ("player", "speak", "response", "session_memory") + + +@dataclass +class ActionRecord: + name: str + description: str = "" + parameters: dict = field(default_factory=lambda: dict(_DEFAULT_PARAMS)) + handler: Optional[Callable] = None + file: str = "" + valid: bool = False + error: str = "" + + +class ActionRegistry: + def __init__(self, actions: dict[str, ActionRecord], logger: Callable[[str], None]): + self._actions = actions # name -> ActionRecord, VALID entries only + self._all_records: list[ActionRecord] = [] + self._logger = logger + + # -- called by main.py at LiveConnectConfig build time -- + def get_tool_declarations(self) -> list[dict]: + return [ + {"name": rec.name, "description": rec.description, "parameters": rec.parameters} + for rec in self._actions.values() + ] + + def has(self, name: str) -> bool: + return name in self._actions + + def names(self) -> set[str]: + return set(self._actions.keys()) + + # -- called by main.py from _execute_tool -- + def run(self, name: str, parameters: dict, ctx: dict | None = None) -> str: + rec = self._actions.get(name) + if rec is None or not rec.valid: + return f"Action '{name}' is not available." + try: + return _call_handler(rec.handler, parameters, ctx or {}) or "Done." + except Exception as e: + self._logger(f"Action '{name}' crashed during run(): {e}") + traceback.print_exc() + return f"Tool '{name}' failed: {e}" + + +def _call_handler(fn: Callable, parameters: dict, ctx: dict) -> str: + """Invoke the handler passing only the context kwargs it actually declares + (or all of them if it has **kwargs), so each action's existing signature + works unchanged.""" + sig = inspect.signature(fn) + has_var_kw = any(p.kind == inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values()) + kwargs = {} + for key in _CTX_KEYS: + if has_var_kw or key in sig.parameters: + kwargs[key] = ctx.get(key) + return fn(parameters=parameters, **kwargs) + + +def _validate(module, filename: str) -> ActionRecord: + """Returns an ActionRecord; .valid=False + .error set on any problem. Never raises.""" + tool = getattr(module, "TOOL", None) + if not isinstance(tool, dict): + return ActionRecord(name=Path(filename).stem, file=filename, + error="No module-level TOOL dict (not a discoverable action).") + + name = tool.get("name") + if not isinstance(name, str) or not _NAME_RE.match(name): + return ActionRecord(name=str(name or Path(filename).stem), file=filename, + error="TOOL['name'] missing or not a valid identifier.") + + description = tool.get("description") + if not isinstance(description, str) or not description.strip(): + return ActionRecord(name=name, file=filename, + error="TOOL['description'] missing or empty.") + + parameters = tool.get("parameters", _DEFAULT_PARAMS) + if not isinstance(parameters, dict) or parameters.get("type") != "OBJECT": + return ActionRecord(name=name, file=filename, + error="TOOL['parameters'] must be a dict with \"type\": \"OBJECT\".") + + handler = tool.get("handler") + if not callable(handler): + return ActionRecord(name=name, file=filename, + error="TOOL['handler'] missing or not callable.") + + return ActionRecord(name=name, description=description.strip(), parameters=parameters, + handler=handler, file=filename, valid=True, error="") + + +def discover_actions(actions_dir: Path, reserved_names: set[str] | None = None, + logger: Callable[[str], None] = print) -> ActionRegistry: + """ + Scans actions_dir for *.py files (skips files starting with '_'). A file is + only treated as an action if it exposes a module-level TOOL dict; files + without one (shared helpers, capture-only modules) are silently ignored. + Import/validation errors and name collisions are logged and the file is + skipped — they NEVER raise out of this function. + """ + reserved = reserved_names or set() + actions_dir.mkdir(parents=True, exist_ok=True) + valid: dict[str, ActionRecord] = {} + all_records: list[ActionRecord] = [] + + files = sorted(actions_dir.glob("*.py"), key=lambda p: p.name) # deterministic order + for path in files: + if path.name.startswith("_"): + continue + try: + module_name = f"actions.{path.stem}" + # Reuse the already-imported module when present so handlers are the + # same objects the rest of the app holds. + module = sys.modules.get(module_name) + if module is None: + spec = importlib.util.spec_from_file_location(module_name, path) + if spec is None or spec.loader is None: + raise ImportError("could not build import spec") + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + try: + spec.loader.exec_module(module) + except Exception: + sys.modules.pop(module_name, None) + raise + + if getattr(module, "TOOL", None) is None: + continue # not an action file — a helper/capture-only module + + rec = _validate(module, path.name) + + if rec.valid and rec.name in reserved: + rec = ActionRecord(name=rec.name, file=path.name, + error=f"Name '{rec.name}' collides with a reserved core tool — rejected.") + elif rec.valid and rec.name in valid: + other = valid[rec.name].file + rec = ActionRecord(name=rec.name, file=path.name, + error=f"Name '{rec.name}' already used by action '{other}' — rejected.") + + except Exception as e: + rec = ActionRecord(name=path.stem, file=path.name, + error=f"Failed to load: {e}") + traceback.print_exc() + + all_records.append(rec) + if rec.valid: + valid[rec.name] = rec + logger(f"Action loaded: {rec.name} ({path.name})") + else: + # Only log a rejection if the file actually tried to be an action. + logger(f"Action rejected: {path.name} — {rec.error}") + + registry = ActionRegistry(valid, logger) + registry._all_records = all_records + logger(f"Action discovery complete: {len(valid)} active.") + return registry diff --git a/core/plugin_loader.py b/core/plugin_loader.py index da79c42..382b3f9 100644 --- a/core/plugin_loader.py +++ b/core/plugin_loader.py @@ -17,7 +17,7 @@ from dataclasses import dataclass, field from pathlib import Path from typing import Callable, Optional -from memory.config_manager import get_plugin_enabled +from memory.config_manager import get_plugin_enabled, get_plugin_config _NAME_RE = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]{0,63}$") _DEFAULT_PARAMS = {"type": "OBJECT", "properties": {}} @@ -32,6 +32,7 @@ class PluginRecord: file: str = "" valid: bool = False error: str = "" + settings: Optional[dict] = None # optional PLUGIN_SETTINGS schema (config fields) class PluginRegistry: @@ -69,6 +70,32 @@ class PluginRegistry: traceback.print_exc() return f"Sir, the '{name}' plugin failed: {e}" + # -- called by ui.py's settings tab to render per-plugin config forms -- + def settings_schemas(self) -> list[dict]: + """One entry per settings SECTION, for enabled plugins that declare a + PLUGIN_SETTINGS schema. Sections are deduped by namespace so a suite of + plugins sharing one namespace (e.g. the printer trio) shows a single + form. Current stored values are merged in so the UI can pre-fill fields. + """ + seen: set[str] = set() + out: list[dict] = [] + for name, rec in self._plugins.items(): + if not rec.settings or not get_plugin_enabled(name): + continue + ns = rec.settings.get("namespace") or rec.name + if ns in seen: + continue + seen.add(ns) + out.append({ + "plugin": rec.name, + "namespace": ns, + "title": rec.settings.get("title") or rec.name, + "fields": rec.settings.get("fields", []), + "values": get_plugin_config(ns), + "action": rec.settings.get("action"), # optional test/connect button + }) + return out + # -- called by ui.py's Plugin Manager overlay -- def list_for_ui(self) -> list[dict]: out = [] @@ -125,8 +152,14 @@ def _validate(module, filename: str) -> PluginRecord: return PluginRecord(name=name, file=filename, error="Missing callable run(parameters, ...) function.") + # Optional, self-describing settings schema (rendered by the settings UI). + # A malformed schema is ignored, never fatal — the plugin still loads. + settings = getattr(module, "PLUGIN_SETTINGS", None) + if not (isinstance(settings, dict) and isinstance(settings.get("fields"), list)): + settings = None + return PluginRecord(name=name, description=description.strip(), parameters=parameters, - run=run_fn, file=filename, valid=True, error="") + run=run_fn, file=filename, valid=True, error="", settings=settings) def discover_plugins(plugins_dir: Path, core_tool_names: set[str], diff --git a/core/prompt.txt b/core/prompt.txt index 591b515..3474ea8 100644 --- a/core/prompt.txt +++ b/core/prompt.txt @@ -14,6 +14,13 @@ Response time: Respond as fast as you can. Act: Always act like Jarvis from Iron Man — professional, efficient, slightly witty. (greet the user warmly in their own language) Length: Match response length to the task. Briefing = short. Complex analysis = thorough. +ACKNOWLEDGE BEFORE A TASK TAKES A MOMENT: +When the user asks for something whose tool needs more than an instant to finish — reading/analyzing an uploaded file, web/news/research/price searches, finding flights, writing or building code, building a project, updating a game — FIRST say exactly ONE short, natural sentence, in the USER'S OWN language, that fits THIS specific request, and THEN call the tool in the same turn. Compose that sentence fresh each time, in your own words, in the language of the conversation — naming what you are about to do. Do NOT reuse a remembered phrase and do NOT lift wording out of a tool's description: anything written there is an explanation for you, not a line for you to say, and repeating it can put you in the wrong language. When the tool returns, deliver the result. +- Purpose: the user should hear you react instantly, so they never feel they're waiting on a frozen assistant. +- Instant actions (opening an app, volume, brightness, a single keypress, a quick setting) need NO acknowledgment — just do them. +- Do NOT pre-acknowledge for screen_process / vision — that flow already tells the user you are looking. +- Keep it to ONE short sentence and vary the wording every time; never a fixed template. It must not talk over the tool's own output. + LANGUAGE: The language of your reply is the language of the user's MOST RECENT message. Nothing else decides it — not what is stored in memory, not the language of these instructions, not the language of a tool's result. Memory may say which language this person has used before. That is a record of the past, not an instruction for now: if they write or speak in another language today, answer in that one. diff --git a/core/wake_word.py b/core/wake_word.py new file mode 100644 index 0000000..f864d6c --- /dev/null +++ b/core/wake_word.py @@ -0,0 +1,202 @@ +""" +Local wake-word detection for JARVIS ("Hey Jarvis"). + +Design goals: + • ZERO cost when the feature is off — openwakeword is imported ONLY inside + start()/install helpers, never at module load. If the user never enables + wake word, none of this touches the app. + • ZERO latency on the audio path — the microphone callback only ever does a + cheap, non-blocking queue push (feed()); the actual model inference runs in + this module's own background thread, so the real-time audio thread and the + Gemini stream are never slowed. + • Fully local & offline — audio fed here never leaves the machine; there is no + network call except the one-time model download the user triggers from the UI. + +openwakeword ships small ONNX models (a few MB each) and runs comfortably on a +CPU. The pretrained wake phrase used here is "Hey Jarvis". +""" +from __future__ import annotations + +import queue +import subprocess +import sys +import threading +from pathlib import Path +from typing import Callable + +# Pretrained openwakeword model that listens for "Hey Jarvis". +WAKE_MODEL = "hey_jarvis" +# Score in [0,1]; above this counts as a detection. Tunable per environment. +DEFAULT_THRESHOLD = 0.5 +# Mic frames arrive at 16 kHz int16; this is just the detector's input rate. +SAMPLE_RATE = 16000 + + +def is_installed() -> bool: + """True if the openwakeword package is importable (no model check).""" + try: + import importlib.util + return importlib.util.find_spec("openwakeword") is not None + except Exception: + return False + + +def is_ready() -> bool: + """True if openwakeword is installed AND its model files are present on disk. + + This is a cheap, DETERMINISTIC file-existence check. It deliberately does NOT + construct a Model to probe readiness — doing that is slow and, worse, can clash + with the detector's own Model when it's already running, which intermittently + returned False and made the UI flicker to 'not downloaded'. Never raises. + """ + if not is_installed(): + return False + try: + import openwakeword + models_dir = Path(openwakeword.__file__).resolve().parent / "resources" / "models" + if not models_dir.is_dir(): + return False + has_wake = (any(models_dir.glob(f"{WAKE_MODEL}*.onnx")) + or any(models_dir.glob(f"{WAKE_MODEL}*.tflite"))) + has_mel = (any(models_dir.glob("melspectrogram*.onnx")) + or any(models_dir.glob("melspectrogram*.tflite"))) + has_emb = (any(models_dir.glob("embedding_model*.onnx")) + or any(models_dir.glob("embedding_model*.tflite"))) + return bool(has_wake and has_mel and has_emb) + except Exception: + return False + + +def install_and_download(logger: Callable[[str], None] = print) -> tuple[bool, str]: + """ + One-click setup for the UI button: pip-install openwakeword if missing, then + download the wake model. Returns (ok, message). Never raises — every failure + is reported through the returned message and the logger. + """ + try: + if not is_installed(): + logger("Wake word: installing openwakeword (one-time)…") + r = subprocess.run( + [sys.executable, "-m", "pip", "install", "openwakeword"], + capture_output=True, text=True, + ) + if r.returncode != 0: + tail = (r.stderr or r.stdout or "").strip().splitlines()[-1:] or [""] + return False, f"pip install failed: {tail[0][:160]}" + # Download the pretrained melspectrogram/embedding + wake models. + logger("Wake word: downloading models…") + try: + import openwakeword.utils as _u + try: + _u.download_models([WAKE_MODEL]) + except TypeError: + _u.download_models() # older signature downloads the default set + except Exception as e: + return False, f"model download failed: {e}" + + if not is_ready(): + return False, "installed, but the wake model could not be loaded." + logger("Wake word: ready.") + return True, "Wake word installed and ready." + except Exception as e: + return False, f"setup error: {e}" + + +class WakeWordDetector: + """ + Runs the wake model in a dedicated thread. The mic thread calls feed() with + raw int16 frames; detections invoke on_detect() (called from this thread — + the callback must marshal to whatever loop/UI it needs). + """ + + def __init__(self, on_detect: Callable[[], None], + threshold: float = DEFAULT_THRESHOLD, + logger: Callable[[str], None] = print): + self._on_detect = on_detect + self._threshold = threshold + self._logger = logger + self._queue: queue.Queue = queue.Queue(maxsize=50) + self._thread: threading.Thread | None = None + self._running = False + self._model = None + self._ready = False + + def start(self) -> bool: + """Load the model and spawn the inference thread. Returns True on success. + Safe to call again — a no-op if already running. Never raises.""" + if self._running: + return True + try: + from openwakeword.model import Model + self._model = Model(wakeword_models=[WAKE_MODEL], inference_framework="onnx") + except Exception as e: + self._logger(f"Wake word: could not load model — {e}") + self._model = None + return False + self._running = True + self._ready = True + self._thread = threading.Thread(target=self._loop, daemon=True, name="WakeWordThread") + self._thread.start() + self._logger("Wake word: listening for 'Hey Jarvis'.") + return True + + def stop(self) -> None: + self._running = False + # unblock the thread if it's waiting on the queue + try: + self._queue.put_nowait(None) + except Exception: + pass + self._model = None + self._ready = False + + @property + def ready(self) -> bool: + return self._ready + + def feed(self, frame_int16) -> None: + """Called from the mic callback (real-time thread). Must stay cheap and + never block — the frame is copied and dropped if the queue is backed up.""" + if not self._running: + return + try: + # frame_int16 is a numpy int16 array (possibly 2-D mono) — flatten to 1-D + data = frame_int16[:, 0].copy() if getattr(frame_int16, "ndim", 1) > 1 else frame_int16.copy() + self._queue.put_nowait(data) + except queue.Full: + pass + except Exception: + pass + + def _loop(self) -> None: + import numpy as np + while self._running: + try: + frame = self._queue.get() + if frame is None or not self._running: + break + scores = self._model.predict(np.asarray(frame, dtype=np.int16)) + score = 0.0 + if isinstance(scores, dict): + # match the jarvis model regardless of exact key suffix + for k, v in scores.items(): + if "jarvis" in k.lower(): + score = max(score, float(v)) + if score == 0.0 and scores: + score = max(float(v) for v in scores.values()) + if score >= self._threshold: + # drain any backlog so we don't double-fire on the same utterance + self._drain() + try: + self._on_detect() + except Exception as e: + self._logger(f"Wake word: on_detect error — {e}") + except Exception as e: + self._logger(f"Wake word: inference error — {e}") + + def _drain(self) -> None: + try: + while True: + self._queue.get_nowait() + except Exception: + pass diff --git a/dashboard/server.py b/dashboard/server.py index d1430c6..00ffc58 100644 --- a/dashboard/server.py +++ b/dashboard/server.py @@ -1,884 +1,794 @@ -""" -dashboard/server.py — JARVIS Local HTTP Dashboard - -Plain HTTP on port 8000 (no SSL warnings, no firewall issues). -Security at the application layer: AES-256-CBC with session-key-derived key. -CryptoJS is auto-downloaded once and served locally — no CDN needed after that. - -Install deps: pip install fastapi "uvicorn[standard]" cryptography -""" - -import asyncio -import base64 -import hashlib -import re -import secrets -import socket -import string -import time -from pathlib import Path - -_DEPS_OK = False -try: - from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Request - from fastapi.responses import HTMLResponse, JSONResponse, FileResponse - import uvicorn - _DEPS_OK = True -except ImportError: - pass - -# python-multipart is required for file uploads — optional dependency -_UPLOAD_OK = False -try: - from fastapi import UploadFile, File as FastAPIFile - _UPLOAD_OK = True -except Exception: - pass - -BASE_DIR = Path(__file__).resolve().parent.parent -STATIC_DIR = Path(__file__).parent / "static" -PORT = 8000 -MAX_UPLOAD_MB = 500 - - -def _make_uploads_dir() -> Path: - """Return (and create) the cross-platform uploads folder.""" - for candidate in [ - Path.home() / "Downloads" / "JARVIS Uploads", - Path.home() / "Documents" / "JARVIS Uploads", - BASE_DIR / "uploads", - ]: - try: - candidate.mkdir(parents=True, exist_ok=True) - return candidate - except Exception: - pass - return BASE_DIR / "uploads" - - -UPLOADS_DIR = _make_uploads_dir() - -def _get_gemini_key() -> str | None: - try: - import json as _json - with open(BASE_DIR / "config" / "api_keys.json", "r", encoding="utf-8") as f: - return _json.load(f).get("gemini_api_key") - except Exception: - return None - -_KEY_CHARS = [c for c in (string.ascii_uppercase + string.digits) - if c not in ('O', 'I', 'L', '0', '1')] - -# ── AES-256-CBC ─────────────────────────────────────────────────────────────── -_AES_SALT = b'JARVIS-DASHBOARD-v1' - - -def _derive_key(session_key: str) -> bytes: - """SHA-256(sessionKey‖salt) → 32-byte AES-256 key (microseconds, no PBKDF2 needed).""" - return hashlib.sha256(session_key.encode('utf-8') + _AES_SALT).digest() - - -def _decrypt_cbc(aes_key: bytes, enc_b64: str) -> str: - """Decrypt base64(IV[16] ‖ ciphertext) with AES-256-CBC + PKCS7.""" - from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes - from cryptography.hazmat.primitives import padding as sym_pad - raw = base64.b64decode(enc_b64) - iv, ct = raw[:16], raw[16:] - dec = Cipher(algorithms.AES(aes_key), modes.CBC(iv)).decryptor() - padded = dec.update(ct) + dec.finalize() - unpadder = sym_pad.PKCS7(128).unpadder() - return (unpadder.update(padded) + unpadder.finalize()).decode('utf-8') - - -# ── CryptoJS (auto-download once, served locally) ───────────────────────────── -_CRYPTOJS_CDN = ("https://cdnjs.cloudflare.com/ajax/libs/" - "crypto-js/4.2.0/crypto-js.min.js") -_CRYPTOJS_FILE = STATIC_DIR / "crypto-js.min.js" - - -def _ensure_network_access(port: int) -> None: - """Cross-platform, best-effort: open port in the OS firewall for LAN access. - - Runs in a background thread — never blocks uvicorn startup. - - Windows : writes a .bat file, runs it elevated via Windows ShellExecuteW - (native UAC dialog, guaranteed to appear). One-time setup. - macOS : osascript admin dialog if the Application Firewall is on. - Linux : pkexec GUI → sudo -n → prints manual command as fallback. - """ - import sys, subprocess, os, tempfile, threading - - # ── Windows ────────────────────────────────────────────────────────────── - if sys.platform == "win32": - import ctypes, time - - port_rule = f"JARVIS Dashboard Port {port}" - prog_rule = "JARVIS Dashboard Python" - py_exe = sys.executable - - def _netsh_rule_exists(name: str) -> bool: - try: - r = subprocess.run( - ["netsh", "advfirewall", "firewall", "show", "rule", f"name={name}"], - capture_output=True, text=True, timeout=5, - ) - return r.returncode == 0 and "No rules match" not in r.stdout - except Exception: - return False - - def _network_is_public() -> bool: - try: - r = subprocess.run( - ["powershell", "-NoProfile", "-NonInteractive", "-Command", - "(Get-NetConnectionProfile | " - "Where-Object {$_.NetworkCategory -eq 'Public'} | " - "Measure-Object).Count"], - capture_output=True, text=True, timeout=6, - ) - return r.stdout.strip() not in ("", "0") - except Exception: - return False - - need_port = not _netsh_rule_exists(port_rule) - need_prog = not _netsh_rule_exists(prog_rule) - need_private = _network_is_public() - - if not need_port and not need_prog and not need_private: - return # already fully configured - - # Build a .bat file — netsh + powershell, runs fast when elevated - bat_lines = ["@echo off"] - if need_private: - bat_lines.append( - 'powershell -NoProfile -NonInteractive -Command "' - 'Get-NetConnectionProfile | ' - "Where-Object {$_.NetworkCategory -eq 'Public'} | " - 'Set-NetConnectionProfile -NetworkCategory Private"' - ) - if need_port: - bat_lines.append( - f'netsh advfirewall firewall add rule ' - f'name="{port_rule}" protocol=TCP dir=in ' - f'localport={port} action=allow' - ) - if need_prog: - bat_lines.append( - f'netsh advfirewall firewall add rule ' - f'name="{prog_rule}" dir=in action=allow ' - f'program="{py_exe}" enable=yes' - ) - - bat_body = "\r\n".join(bat_lines) + "\r\n" - fd, bat_path = tempfile.mkstemp(suffix=".bat", prefix="jarvis_fw_") - try: - os.write(fd, bat_body.encode("mbcs")) # Windows cmd.exe expects ANSI - os.close(fd) - except Exception: - try: - os.close(fd) - except Exception: - pass - return - - # ── Try running directly (succeeds when already admin) ──────────────── - try: - r = subprocess.run( - [bat_path], capture_output=True, timeout=8, shell=True - ) - if r.returncode == 0: - print(f"[Dashboard] Firewall configured for port {port}.") - try: - os.unlink(bat_path) - except Exception: - pass - return - except Exception: - pass - - # ── ShellExecuteW: native UAC elevation (most reliable on Windows) ──── - # ShellExecuteW with verb "runas" always shows the UAC dialog regardless - # of UAC level settings. Non-blocking — uvicorn is already running. - print("[Dashboard] One-time network setup required.") - print("[Dashboard] >>> A Windows security dialog will appear — click 'Yes' <<<") - try: - ret = ctypes.windll.shell32.ShellExecuteW( - None, # hwnd (no parent window) - "runas", # verb (request elevation) - bat_path, # file (our .bat) - None, # params - None, # working dir - 0, # SW_HIDE (run without a visible cmd window) - ) - if int(ret) > 32: - # ShellExecuteW returns immediately; bat finishes in ~1 second. - # Sleep briefly so the rules are in place before the first retry. - time.sleep(2) - print(f"[Dashboard] Network setup complete — port {port} is open.") - print("[Dashboard] Refresh your phone browser to connect.") - else: - print("[Dashboard] Setup was not allowed.") - print("[Dashboard] Phone connections may fail until JARVIS is run as Administrator.") - except Exception as e: - print(f"[Dashboard] Firewall setup error: {e}") - finally: - # Cleanup after the bat has had time to run - def _cleanup(path: str) -> None: - time.sleep(5) - try: - os.unlink(path) - except Exception: - pass - threading.Thread(target=_cleanup, args=(bat_path,), daemon=True).start() - return - - # ── macOS ───────────────────────────────────────────────────────────────── - if sys.platform == "darwin": - fw_ctl = "/usr/libexec/ApplicationFirewall/socketfilterfw" - try: - r = subprocess.run( - [fw_ctl, "--getglobalstate"], capture_output=True, text=True, timeout=5, - ) - if "disabled" in r.stdout.lower(): - return # firewall off — nothing to do - - py = sys.executable - listed = subprocess.run( - [fw_ctl, "--listapps"], capture_output=True, text=True, timeout=5, - ) - if py in listed.stdout: - return # already allowed - - print("[Dashboard] One-time network setup — enter your password in the macOS dialog.") - subprocess.run( - ["osascript", "-e", - f'do shell script "{fw_ctl} --add {py} && {fw_ctl} --unblockapp {py}"' - f' with administrator privileges'], - timeout=60, - ) - except Exception: - pass # macOS firewall is off by default — silent failure is fine - return - - # ── Linux ───────────────────────────────────────────────────────────────── - def _privileged(cmd: list[str]) -> bool: - for prefix in (["pkexec"], ["sudo", "-n"]): - try: - r = subprocess.run(prefix + cmd, capture_output=True, timeout=30) - if r.returncode == 0: - return True - except Exception: - pass - return False - - try: # ufw - r = subprocess.run(["ufw", "status"], capture_output=True, text=True, timeout=5) - if "active" in r.stdout.lower(): - if _privileged(["ufw", "allow", f"{port}/tcp"]): - print(f"[Dashboard] ufw: port {port} allowed.") - else: - print(f"[Dashboard] Run manually: sudo ufw allow {port}/tcp") - return - except FileNotFoundError: - pass - - try: # firewalld - r = subprocess.run( - ["firewall-cmd", "--state"], capture_output=True, text=True, timeout=5, - ) - if "running" in r.stdout.lower(): - ok = (_privileged(["firewall-cmd", "--add-port", f"{port}/tcp", "--permanent"]) - and _privileged(["firewall-cmd", "--reload"])) - if ok: - print(f"[Dashboard] firewalld: port {port} allowed.") - else: - print(f"[Dashboard] Run manually: sudo firewall-cmd --add-port={port}/tcp --permanent && sudo firewall-cmd --reload") - return - except FileNotFoundError: - pass - - try: # iptables (not persistent but works until reboot) - r = subprocess.run(["iptables", "-L", "INPUT", "-n"], capture_output=True, timeout=5) - if r.returncode == 0: - if _privileged(["iptables", "-A", "INPUT", "-p", "tcp", "--dport", str(port), "-j", "ACCEPT"]): - print(f"[Dashboard] iptables: port {port} opened.") - else: - print(f"[Dashboard] Run manually: sudo iptables -A INPUT -p tcp --dport {port} -j ACCEPT") - except FileNotFoundError: - pass # no iptables means firewall is probably off — nothing to do - - -def _ensure_crypto_js() -> None: - if _CRYPTOJS_FILE.exists(): - return - try: - import urllib.request - print("[Dashboard] Downloading CryptoJS (one-time setup)…") - urllib.request.urlretrieve(_CRYPTOJS_CDN, str(_CRYPTOJS_FILE)) - print("[Dashboard] CryptoJS cached — will serve locally from now on.") - except Exception as e: - print(f"[Dashboard] CryptoJS download failed: {e}") - print(f"[Dashboard] Encryption will fall back to CDN load on client.") - - -_ensure_crypto_js() - - -# ── helpers ─────────────────────────────────────────────────────────────────── - -def _local_ip() -> str: - """Return the best LAN-facing IPv4 address, no internet required.""" - # Method 1: route trick (fast, works when internet is available) - for probe in ("8.8.8.8", "1.1.1.1", "192.168.1.1"): - try: - s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - s.settimeout(0.5) - s.connect((probe, 80)) - ip = s.getsockname()[0] - s.close() - if not ip.startswith("127."): - return ip - except Exception: - pass - - # Method 2: hostname resolution (works offline on most systems) - try: - ip = socket.gethostbyname(socket.gethostname()) - if not ip.startswith("127."): - return ip - except Exception: - pass - - # Method 3: enumerate all interfaces (fully offline, no external deps) - try: - for info in socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET): - ip = info[4][0] - if not ip.startswith("127.") and not ip.startswith("169.254."): - return ip - except Exception: - pass - - return "127.0.0.1" - - -def _ensure_certs() -> bool: - """ - Make sure config/certs holds a TLS key pair, generating a self-signed one the - first time the dashboard runs. - - The pair is deliberately NOT shipped in the repository. A private key that - every user downloads is the same as having no private key at all: anyone can - present a certificate that matches it. Generating locally gives each install - its own key, costs about a second, and happens exactly once. - - Returns True when a usable pair exists afterwards; False leaves the caller on - plain HTTP, which still works — the QR code simply encodes http:// instead. - """ - certs = BASE_DIR / "config" / "certs" - key_p = certs / "jarvis.key" - crt_p = certs / "jarvis.crt" - if key_p.exists() and crt_p.exists(): - return True - - try: - import datetime - import ipaddress - from cryptography import x509 - from cryptography.hazmat.primitives import hashes, serialization - from cryptography.hazmat.primitives.asymmetric import rsa - from cryptography.x509.oid import NameOID - except ImportError: - print("[Dashboard] cryptography not installed — serving over plain HTTP.") - print("[Dashboard] For HTTPS run: pip install cryptography") - return False - - try: - certs.mkdir(parents=True, exist_ok=True) - key = rsa.generate_private_key(public_exponent=65537, key_size=2048) - - who = x509.Name([ - x509.NameAttribute(NameOID.COMMON_NAME, "JARVIS Dashboard"), - x509.NameAttribute(NameOID.ORGANIZATION_NAME, "JARVIS"), - ]) - - # The SAN has to cover every address the phone might use: the LAN IP the - # QR code encodes, plus localhost when testing on the machine itself. - alt = [x509.DNSName("localhost"), - x509.IPAddress(ipaddress.IPv4Address("127.0.0.1"))] - try: - lan = _local_ip() - if not lan.startswith("127."): - alt.append(x509.IPAddress(ipaddress.IPv4Address(lan))) - except Exception: - pass # no LAN address resolvable — localhost entries still work - - # Timezone-aware UTC: datetime.utcnow() is deprecated from Python 3.12 on, - # and the builder normalises aware values to UTC itself. - now = datetime.datetime.now(datetime.timezone.utc) - cert = ( - x509.CertificateBuilder() - .subject_name(who) - .issuer_name(who) - .public_key(key.public_key()) - .serial_number(x509.random_serial_number()) - .not_valid_before(now - datetime.timedelta(days=1)) - .not_valid_after(now + datetime.timedelta(days=3650)) - .add_extension(x509.SubjectAlternativeName(alt), critical=False) - .add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True) - .sign(key, hashes.SHA256()) - ) - - key_p.write_bytes(key.private_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PrivateFormat.TraditionalOpenSSL, - encryption_algorithm=serialization.NoEncryption(), - )) - crt_p.write_bytes(cert.public_bytes(serialization.Encoding.PEM)) - - try: - import os as _os - _os.chmod(key_p, 0o600) # best effort — largely a no-op on Windows - except Exception: - pass - - print(f"[Dashboard] Generated a self-signed certificate for this machine: {certs}") - return True - except Exception as e: - print(f"[Dashboard] Certificate generation failed ({e}) — serving over plain HTTP.") - return False - - -def _read(name: str) -> str: - return (STATIC_DIR / name).read_text(encoding="utf-8") - - -# ── DashboardServer ─────────────────────────────────────────────────────────── - -class DashboardServer: - - def __init__(self): - self._ip = _local_ip() - self._tokens: set[str] = set() - self._token_keys: dict[str, str] = {} # auth_token → session_key - self._aes_cache: dict[str, bytes]= {} # session_key → AES bytes - self._clients: set[WebSocket] = set() - self._history: list[dict] = [] - self._command_queue = asyncio.Queue() - self._wake_callback = None - self._connect_callback = None - self._pending_keys: dict[str, float] = {} - self._device_sessions: dict[str, dict] = {} # device_token → {session_key} - self._phone_audio_queue: asyncio.Queue = asyncio.Queue(maxsize=200) - self._uploads_dir = UPLOADS_DIR - self._login_html = _read("login.html") - self._app_html = _read("app.html") - self.app = self._build_app() - - # ── one-time key management ─────────────────────────────────────────── - - def new_key(self, expiry_secs: int = 600) -> str: - now = time.time() - self._pending_keys = {k: v for k, v in self._pending_keys.items() if v > now} - key = ''.join(secrets.choice(_KEY_CHARS) for _ in range(6)) - self._pending_keys[key] = now + expiry_secs - return key - - @staticmethod - def _ssl_enabled() -> bool: - certs = BASE_DIR / "config" / "certs" - return (certs / "jarvis.key").exists() and (certs / "jarvis.crt").exists() - - def get_url(self) -> str: - proto = "https" if self._ssl_enabled() else "http" - return f"{proto}://{self._ip}:{PORT}" - - def get_manual_url(self) -> str: - """URL for manual browser entry. When HTTPS active, points to alias port (also HTTPS).""" - if self._ssl_enabled(): - return f"{self._ip}:{PORT + 1}" - return f"{self._ip}:{PORT}" - - def _aes_key(self, session_key: str) -> bytes: - if session_key not in self._aes_cache: - self._aes_cache[session_key] = _derive_key(session_key) - return self._aes_cache[session_key] - - def _decrypt(self, token: str, enc_b64: str) -> str | None: - sk = self._token_keys.get(token) - if not sk: - return None - try: - return _decrypt_cbc(self._aes_key(sk), enc_b64) - except Exception: - return None - - # ── callbacks ──────────────────────────────────────────────────────── - - def set_wake_callback(self, fn) -> None: - self._wake_callback = fn - - def set_connect_callback(self, fn) -> None: - self._connect_callback = fn - - # ── broadcast ──────────────────────────────────────────────────────── - - async def broadcast(self, msg: dict) -> None: - self._history.append(msg) - if len(self._history) > 300: - self._history = self._history[-300:] - dead: set[WebSocket] = set() - for ws in list(self._clients): - try: - await ws.send_json(msg) - except Exception: - dead.add(ws) - self._clients -= dead - - # ── FastAPI app ─────────────────────────────────────────────────────── - - def _build_app(self) -> "FastAPI": - app = FastAPI(docs_url=None, redoc_url=None) - - def _auth(req: Request) -> bool: - tok = req.headers.get("authorization", "").removeprefix("Bearer ").strip() - return bool(tok) and tok in self._tokens - - # serve CryptoJS from local cache, fallback to CDN redirect - @app.get("/static/crypto.js") - async def serve_crypto(): - if _CRYPTOJS_FILE.exists(): - return FileResponse(str(_CRYPTOJS_FILE), - media_type="application/javascript") - from fastapi.responses import RedirectResponse - return RedirectResponse(_CRYPTOJS_CDN) - - @app.get("/login", response_class=HTMLResponse) - async def login_page(): - return HTMLResponse(self._login_html) - - @app.get("/", response_class=HTMLResponse) - async def index(): - # Auth is handled client-side via sessionStorage bearer token. - # Server-side header auth can't work here because browser navigations - # don't send custom headers (location.href doesn't carry Authorization). - html = (self._app_html - .replace("__IP__", self._ip) - .replace("__PORT__", str(PORT))) - return HTMLResponse(html) - - @app.post("/login") - async def login(req: Request): - body = await req.json() - entered = str(body.get("pin", "")).strip().upper() - now = time.time() - if entered in self._pending_keys and self._pending_keys[entered] > now: - del self._pending_keys[entered] # one-time use - tok = secrets.token_urlsafe(32) - self._tokens.add(tok) - self._token_keys[tok] = entered - self._aes_key(entered) # pre-derive & cache - if self._connect_callback: - self._connect_callback() - asyncio.create_task(self.broadcast( - {"type": "sys", "text": "Remote connection established."} - )) - # Bearer token in response body — no cookies needed (works on any browser/HTTP) - return JSONResponse({"ok": True, "token": tok}) - return JSONResponse({"ok": False, "error": "Invalid or expired key"}, - status_code=401) - - @app.get("/auto-login") - async def auto_login(key: str = ""): - """QR code target — validates one-time key, creates session, redirects phone.""" - now = time.time() - if not key or key not in self._pending_keys or self._pending_keys[key] <= now: - return HTMLResponse(""" -
- -Press Remote Control in JARVIS to get a new QR code.
-Connecting to JARVIS…
-""") - - @app.post("/api/device-login") - async def device_login_ep(req: Request): - """Return a fresh auth token for a previously paired device token.""" - try: - body = await req.json() - except Exception: - return JSONResponse({"ok": False}, status_code=400) - dev_tok = (body.get("device_token") or "").strip() - if not dev_tok or dev_tok not in self._device_sessions: - return JSONResponse({"ok": False}, status_code=401) - session_key = self._device_sessions[dev_tok]["session_key"] - tok = secrets.token_urlsafe(32) - self._tokens.add(tok) - self._token_keys[tok] = session_key - self._aes_key(session_key) - if self._connect_callback: - self._connect_callback() - asyncio.create_task(self.broadcast( - {"type": "sys", "text": "Known device reconnected automatically."} - )) - return JSONResponse({"ok": True, "token": tok, "key": session_key}) - - @app.post("/api/revoke-devices") - async def revoke_devices(req: Request): - """Invalidate all persistent device tokens (admin action).""" - if not _auth(req): - return JSONResponse({"error": "Unauthorized"}, status_code=401) - count = len(self._device_sessions) - self._device_sessions.clear() - return JSONResponse({"ok": True, "revoked": count}) - - @app.post("/api/command") - async def command(req: Request): - if not _auth(req): - return JSONResponse({"error": "Unauthorized"}, status_code=401) - body = await req.json() - token = req.headers.get("authorization", "").removeprefix("Bearer ").strip() - enc = body.get("enc", "") - if enc: - text = self._decrypt(token, enc) - if text is None: - return JSONResponse({"error": "Decryption failed"}, status_code=400) - else: - text = (body.get("text") or "").strip() - if text: - await self._command_queue.put(text) - if self._wake_callback: - self._wake_callback() - return JSONResponse({"ok": True}) - - @app.post("/api/wake") - async def wake_ep(req: Request): - if not _auth(req): - return JSONResponse({"error": "Unauthorized"}, status_code=401) - if self._wake_callback: - self._wake_callback() - return JSONResponse({"ok": True}) - - # ── Phone mic real-time audio → Gemini Live ────────────────────────── - - @app.websocket("/ws/phone-audio") - async def phone_audio_ws(websocket: WebSocket, token: str = ""): - tok = token.strip() - if not tok or tok not in self._tokens: - await websocket.close(code=4001) - return - await websocket.accept() - asyncio.create_task(self.broadcast( - {"type": "sys", "text": "Phone microphone live."} - )) - try: - while True: - data = await websocket.receive_bytes() - try: - self._phone_audio_queue.put_nowait( - {"data": data, "mime_type": "audio/pcm"} - ) - except asyncio.QueueFull: - pass # drop frame rather than block - except WebSocketDisconnect: - pass - finally: - asyncio.create_task(self.broadcast( - {"type": "sys", "text": "Phone microphone stopped."} - )) - - # ── File sharing ────────────────────────────────────────────────────── - - def _safe_filename(raw: str) -> str: - name = Path(raw).name # strip path components - name = re.sub(r'[<>:"/\\|?*\x00-\x1f]', '_', name).strip(". ") - return name or "upload" - - if _UPLOAD_OK: - @app.post("/api/upload") - async def upload_file(req: Request, file: UploadFile = FastAPIFile(...)): - if not _auth(req): - return JSONResponse({"error": "Unauthorized"}, status_code=401) - - safe = _safe_filename(file.filename or "upload") - dest = self._uploads_dir / safe - stem, suffix = Path(safe).stem, Path(safe).suffix - counter = 1 - while dest.exists(): - dest = self._uploads_dir / f"{stem}_{counter}{suffix}" - counter += 1 - - size = 0 - max_bytes = MAX_UPLOAD_MB * 1024 * 1024 - try: - with open(dest, "wb") as fout: - while True: - chunk = await file.read(65536) - if not chunk: - break - size += len(chunk) - if size > max_bytes: - fout.close() - dest.unlink(missing_ok=True) - return JSONResponse( - {"error": f"File too large (max {MAX_UPLOAD_MB} MB)"}, - status_code=413, - ) - fout.write(chunk) - except Exception as exc: - try: - dest.unlink(missing_ok=True) - except Exception: - pass - return JSONResponse({"error": str(exc)}, status_code=500) - - asyncio.create_task(self.broadcast({ - "type": "file_received", - "name": dest.name, - "size": size, - "saved_to": str(self._uploads_dir), - })) - return JSONResponse({"ok": True, "name": dest.name, "size": size}) - else: - @app.post("/api/upload") - async def upload_unavailable(req: Request): - return JSONResponse( - {"error": "File uploads require: pip install python-multipart"}, - status_code=503, - ) - - @app.get("/api/files") - async def list_files(req: Request): - if not _auth(req): - return JSONResponse({"error": "Unauthorized"}, status_code=401) - files = [] - try: - for f in sorted( - (p for p in self._uploads_dir.iterdir() if p.is_file()), - key=lambda p: p.stat().st_mtime, - reverse=True, - ): - files.append({"name": f.name, "size": f.stat().st_size}) - except Exception: - pass - return JSONResponse({"files": files}) - - @app.get("/uploads/{filename}") - async def download_file(filename: str, token: str = ""): - # Auth via query param — browser can't send custom headers - tok = token.strip() - if not tok or tok not in self._tokens: - return JSONResponse({"error": "Unauthorized"}, status_code=401) - safe = re.sub(r'[/\\]', '', filename) - path = self._uploads_dir / safe - if not path.exists() or not path.is_file(): - return JSONResponse({"error": "Not found"}, status_code=404) - return FileResponse(str(path), filename=safe) - - @app.websocket("/ws") - async def ws_ep(websocket: WebSocket, token: str = ""): - tok = token.strip() - if not tok or tok not in self._tokens: - await websocket.close(code=4001) - return - await websocket.accept() - self._clients.add(websocket) - for entry in self._history[-50:]: - try: - await websocket.send_json(entry) - except Exception: - break - try: - while True: - data = await websocket.receive_json() - if data.get("type") == "command": - enc = data.get("enc", "") - t = self._decrypt(tok, enc) if enc else (data.get("text") or "").strip() - if t: - await self._command_queue.put(t) - if self._wake_callback: - self._wake_callback() - except WebSocketDisconnect: - pass - finally: - self._clients.discard(websocket) - - return app - - # ── serve ───────────────────────────────────────────────────────────── - - async def _serve_alias(self) -> None: - """Second HTTPS server on PORT+1 sharing the same app and in-memory state. - Chrome HTTPS-upgrades any bare IP:PORT the user types, so this port also needs TLS. - User types IP:8001 → Chrome tries https → self-signed cert warning → accept once → done.""" - ssl_key = BASE_DIR / "config" / "certs" / "jarvis.key" - ssl_cert = BASE_DIR / "config" / "certs" / "jarvis.crt" - asyncio.get_event_loop().run_in_executor(None, _ensure_network_access, PORT + 1) - cfg = uvicorn.Config( - self.app, host="0.0.0.0", port=PORT + 1, log_level="warning", - ssl_keyfile=str(ssl_key), ssl_certfile=str(ssl_cert), - ) - print(f"[Dashboard] Manual entry: {self._ip}:{PORT + 1} (type in browser, accept cert once)") - await uvicorn.Server(cfg).serve() - - async def serve(self) -> None: - if not _DEPS_OK: - print("[Dashboard] fastapi/uvicorn not installed — dashboard disabled.") - print("[Dashboard] Run: pip install fastapi 'uvicorn[standard]' cryptography") - return - - # Firewall setup runs in a thread — uvicorn starts immediately, - # no waiting for UAC dialogs or subprocess timeouts. - asyncio.get_event_loop().run_in_executor(None, _ensure_network_access, PORT) - - # Generate the TLS pair on first run so no private key ships in the repo. - _ensure_certs() - - use_ssl = self._ssl_enabled() - ssl_key = BASE_DIR / "config" / "certs" / "jarvis.key" - ssl_cert = BASE_DIR / "config" / "certs" / "jarvis.crt" - - if use_ssl: - asyncio.create_task(self._serve_alias()) - - cfg = uvicorn.Config( - self.app, host="0.0.0.0", port=PORT, log_level="warning", - **({"ssl_keyfile": str(ssl_key), "ssl_certfile": str(ssl_cert)} if use_ssl else {}), - ) - - proto = "https" if use_ssl else "http" - print(f"[Dashboard] {proto}://{self._ip}:{PORT}") - print("[Dashboard] Press 'Remote Control' in JARVIS UI to get the QR code.") - await uvicorn.Server(cfg).serve() +""" +dashboard/server.py — JARVIS Local HTTP Dashboard + +Plain HTTP on port 8000 (no SSL warnings, no firewall issues). +Security at the application layer: AES-256-CBC with session-key-derived key. +CryptoJS is auto-downloaded once and served locally — no CDN needed after that. + +Install deps: pip install fastapi "uvicorn[standard]" cryptography +""" + +import asyncio +import base64 +import hashlib +import re +import secrets +import socket +import string +import time +from pathlib import Path + +_DEPS_OK = False +try: + from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Request + from fastapi.responses import HTMLResponse, JSONResponse, FileResponse + import uvicorn + _DEPS_OK = True +except ImportError: + pass + +# python-multipart is required for file uploads — optional dependency +_UPLOAD_OK = False +try: + from fastapi import UploadFile, File as FastAPIFile + _UPLOAD_OK = True +except Exception: + pass + +BASE_DIR = Path(__file__).resolve().parent.parent +STATIC_DIR = Path(__file__).parent / "static" +PORT = 8000 +MAX_UPLOAD_MB = 500 + + +def _make_uploads_dir() -> Path: + """Return (and create) the cross-platform uploads folder.""" + for candidate in [ + Path.home() / "Downloads" / "JARVIS Uploads", + Path.home() / "Documents" / "JARVIS Uploads", + BASE_DIR / "uploads", + ]: + try: + candidate.mkdir(parents=True, exist_ok=True) + return candidate + except Exception: + pass + return BASE_DIR / "uploads" + + +UPLOADS_DIR = _make_uploads_dir() + +def _get_gemini_key() -> str | None: + try: + import json as _json + with open(BASE_DIR / "config" / "api_keys.json", "r", encoding="utf-8") as f: + return _json.load(f).get("gemini_api_key") + except Exception: + return None + +_KEY_CHARS = [c for c in (string.ascii_uppercase + string.digits) + if c not in ('O', 'I', 'L', '0', '1')] + +# ── AES-256-CBC ─────────────────────────────────────────────────────────────── +_AES_SALT = b'JARVIS-DASHBOARD-v1' + + +def _derive_key(session_key: str) -> bytes: + """SHA-256(sessionKey‖salt) → 32-byte AES-256 key (microseconds, no PBKDF2 needed).""" + return hashlib.sha256(session_key.encode('utf-8') + _AES_SALT).digest() + + +def _decrypt_cbc(aes_key: bytes, enc_b64: str) -> str: + """Decrypt base64(IV[16] ‖ ciphertext) with AES-256-CBC + PKCS7.""" + from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes + from cryptography.hazmat.primitives import padding as sym_pad + raw = base64.b64decode(enc_b64) + iv, ct = raw[:16], raw[16:] + dec = Cipher(algorithms.AES(aes_key), modes.CBC(iv)).decryptor() + padded = dec.update(ct) + dec.finalize() + unpadder = sym_pad.PKCS7(128).unpadder() + return (unpadder.update(padded) + unpadder.finalize()).decode('utf-8') + + +# ── CryptoJS (auto-download once, served locally) ───────────────────────────── +_CRYPTOJS_CDN = ("https://cdnjs.cloudflare.com/ajax/libs/" + "crypto-js/4.2.0/crypto-js.min.js") +_CRYPTOJS_FILE = STATIC_DIR / "crypto-js.min.js" + + +def _ensure_network_access(port: int) -> None: + """Cross-platform, best-effort: open port in the OS firewall for LAN access. + + Runs in a background thread — never blocks uvicorn startup. + + Windows : writes a .bat file, runs it elevated via Windows ShellExecuteW + (native UAC dialog, guaranteed to appear). One-time setup. + macOS : osascript admin dialog if the Application Firewall is on. + Linux : pkexec GUI → sudo -n → prints manual command as fallback. + """ + import sys, subprocess, os, tempfile, threading + + # ── Windows ────────────────────────────────────────────────────────────── + if sys.platform == "win32": + import ctypes, time + + port_rule = f"JARVIS Dashboard Port {port}" + prog_rule = "JARVIS Dashboard Python" + py_exe = sys.executable + + def _netsh_rule_exists(name: str) -> bool: + try: + r = subprocess.run( + ["netsh", "advfirewall", "firewall", "show", "rule", f"name={name}"], + capture_output=True, text=True, timeout=5, + ) + return r.returncode == 0 and "No rules match" not in r.stdout + except Exception: + return False + + def _network_is_public() -> bool: + try: + r = subprocess.run( + ["powershell", "-NoProfile", "-NonInteractive", "-Command", + "(Get-NetConnectionProfile | " + "Where-Object {$_.NetworkCategory -eq 'Public'} | " + "Measure-Object).Count"], + capture_output=True, text=True, timeout=6, + ) + return r.stdout.strip() not in ("", "0") + except Exception: + return False + + need_port = not _netsh_rule_exists(port_rule) + need_prog = not _netsh_rule_exists(prog_rule) + need_private = _network_is_public() + + if not need_port and not need_prog and not need_private: + return # already fully configured + + # Build a .bat file — netsh + powershell, runs fast when elevated + bat_lines = ["@echo off"] + if need_private: + bat_lines.append( + 'powershell -NoProfile -NonInteractive -Command "' + 'Get-NetConnectionProfile | ' + "Where-Object {$_.NetworkCategory -eq 'Public'} | " + 'Set-NetConnectionProfile -NetworkCategory Private"' + ) + if need_port: + bat_lines.append( + f'netsh advfirewall firewall add rule ' + f'name="{port_rule}" protocol=TCP dir=in ' + f'localport={port} action=allow' + ) + if need_prog: + bat_lines.append( + f'netsh advfirewall firewall add rule ' + f'name="{prog_rule}" dir=in action=allow ' + f'program="{py_exe}" enable=yes' + ) + + bat_body = "\r\n".join(bat_lines) + "\r\n" + fd, bat_path = tempfile.mkstemp(suffix=".bat", prefix="jarvis_fw_") + try: + os.write(fd, bat_body.encode("mbcs")) # Windows cmd.exe expects ANSI + os.close(fd) + except Exception: + try: + os.close(fd) + except Exception: + pass + return + + # ── Try running directly (succeeds when already admin) ──────────────── + try: + r = subprocess.run( + [bat_path], capture_output=True, timeout=8, shell=True + ) + if r.returncode == 0: + print(f"[Dashboard] Firewall configured for port {port}.") + try: + os.unlink(bat_path) + except Exception: + pass + return + except Exception: + pass + + # ── ShellExecuteW: native UAC elevation (most reliable on Windows) ──── + # ShellExecuteW with verb "runas" always shows the UAC dialog regardless + # of UAC level settings. Non-blocking — uvicorn is already running. + print("[Dashboard] One-time network setup required.") + print("[Dashboard] >>> A Windows security dialog will appear — click 'Yes' <<<") + try: + ret = ctypes.windll.shell32.ShellExecuteW( + None, # hwnd (no parent window) + "runas", # verb (request elevation) + bat_path, # file (our .bat) + None, # params + None, # working dir + 0, # SW_HIDE (run without a visible cmd window) + ) + if int(ret) > 32: + # ShellExecuteW returns immediately; bat finishes in ~1 second. + # Sleep briefly so the rules are in place before the first retry. + time.sleep(2) + print(f"[Dashboard] Network setup complete — port {port} is open.") + print("[Dashboard] Refresh your phone browser to connect.") + else: + print("[Dashboard] Setup was not allowed.") + print("[Dashboard] Phone connections may fail until JARVIS is run as Administrator.") + except Exception as e: + print(f"[Dashboard] Firewall setup error: {e}") + finally: + # Cleanup after the bat has had time to run + def _cleanup(path: str) -> None: + time.sleep(5) + try: + os.unlink(path) + except Exception: + pass + threading.Thread(target=_cleanup, args=(bat_path,), daemon=True).start() + return + + # ── macOS ───────────────────────────────────────────────────────────────── + if sys.platform == "darwin": + fw_ctl = "/usr/libexec/ApplicationFirewall/socketfilterfw" + try: + r = subprocess.run( + [fw_ctl, "--getglobalstate"], capture_output=True, text=True, timeout=5, + ) + if "disabled" in r.stdout.lower(): + return # firewall off — nothing to do + + py = sys.executable + listed = subprocess.run( + [fw_ctl, "--listapps"], capture_output=True, text=True, timeout=5, + ) + if py in listed.stdout: + return # already allowed + + print("[Dashboard] One-time network setup — enter your password in the macOS dialog.") + subprocess.run( + ["osascript", "-e", + f'do shell script "{fw_ctl} --add {py} && {fw_ctl} --unblockapp {py}"' + f' with administrator privileges'], + timeout=60, + ) + except Exception: + pass # macOS firewall is off by default — silent failure is fine + return + + # ── Linux ───────────────────────────────────────────────────────────────── + def _privileged(cmd: list[str]) -> bool: + for prefix in (["pkexec"], ["sudo", "-n"]): + try: + r = subprocess.run(prefix + cmd, capture_output=True, timeout=30) + if r.returncode == 0: + return True + except Exception: + pass + return False + + try: # ufw + r = subprocess.run(["ufw", "status"], capture_output=True, text=True, timeout=5) + if "active" in r.stdout.lower(): + if _privileged(["ufw", "allow", f"{port}/tcp"]): + print(f"[Dashboard] ufw: port {port} allowed.") + else: + print(f"[Dashboard] Run manually: sudo ufw allow {port}/tcp") + return + except FileNotFoundError: + pass + + try: # firewalld + r = subprocess.run( + ["firewall-cmd", "--state"], capture_output=True, text=True, timeout=5, + ) + if "running" in r.stdout.lower(): + ok = (_privileged(["firewall-cmd", "--add-port", f"{port}/tcp", "--permanent"]) + and _privileged(["firewall-cmd", "--reload"])) + if ok: + print(f"[Dashboard] firewalld: port {port} allowed.") + else: + print(f"[Dashboard] Run manually: sudo firewall-cmd --add-port={port}/tcp --permanent && sudo firewall-cmd --reload") + return + except FileNotFoundError: + pass + + try: # iptables (not persistent but works until reboot) + r = subprocess.run(["iptables", "-L", "INPUT", "-n"], capture_output=True, timeout=5) + if r.returncode == 0: + if _privileged(["iptables", "-A", "INPUT", "-p", "tcp", "--dport", str(port), "-j", "ACCEPT"]): + print(f"[Dashboard] iptables: port {port} opened.") + else: + print(f"[Dashboard] Run manually: sudo iptables -A INPUT -p tcp --dport {port} -j ACCEPT") + except FileNotFoundError: + pass # no iptables means firewall is probably off — nothing to do + + +def _ensure_crypto_js() -> None: + if _CRYPTOJS_FILE.exists(): + return + try: + import urllib.request + print("[Dashboard] Downloading CryptoJS (one-time setup)…") + urllib.request.urlretrieve(_CRYPTOJS_CDN, str(_CRYPTOJS_FILE)) + print("[Dashboard] CryptoJS cached — will serve locally from now on.") + except Exception as e: + print(f"[Dashboard] CryptoJS download failed: {e}") + print(f"[Dashboard] Encryption will fall back to CDN load on client.") + + +_ensure_crypto_js() + + +# ── helpers ─────────────────────────────────────────────────────────────────── + +def _local_ip() -> str: + """Return the best LAN-facing IPv4 address, no internet required.""" + # Method 1: route trick (fast, works when internet is available) + for probe in ("8.8.8.8", "1.1.1.1", "192.168.1.1"): + try: + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.settimeout(0.5) + s.connect((probe, 80)) + ip = s.getsockname()[0] + s.close() + if not ip.startswith("127."): + return ip + except Exception: + pass + + # Method 2: hostname resolution (works offline on most systems) + try: + ip = socket.gethostbyname(socket.gethostname()) + if not ip.startswith("127."): + return ip + except Exception: + pass + + # Method 3: enumerate all interfaces (fully offline, no external deps) + try: + for info in socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET): + ip = info[4][0] + if not ip.startswith("127.") and not ip.startswith("169.254."): + return ip + except Exception: + pass + + return "127.0.0.1" + + +def _read(name: str) -> str: + return (STATIC_DIR / name).read_text(encoding="utf-8") + + +# ── DashboardServer ─────────────────────────────────────────────────────────── + +class DashboardServer: + + def __init__(self): + self._ip = _local_ip() + self._tokens: set[str] = set() + self._token_keys: dict[str, str] = {} # auth_token → session_key + self._aes_cache: dict[str, bytes]= {} # session_key → AES bytes + self._clients: set[WebSocket] = set() + self._history: list[dict] = [] + self._command_queue = asyncio.Queue() + self._wake_callback = None + self._connect_callback = None + self._pending_keys: dict[str, float] = {} + self._device_sessions: dict[str, dict] = {} # device_token → {session_key} + self._phone_audio_queue: asyncio.Queue = asyncio.Queue(maxsize=200) + self._uploads_dir = UPLOADS_DIR + self._login_html = _read("login.html") + self._app_html = _read("app.html") + self.app = self._build_app() + + # ── one-time key management ─────────────────────────────────────────── + + def new_key(self, expiry_secs: int = 600) -> str: + now = time.time() + self._pending_keys = {k: v for k, v in self._pending_keys.items() if v > now} + key = ''.join(secrets.choice(_KEY_CHARS) for _ in range(6)) + self._pending_keys[key] = now + expiry_secs + return key + + @staticmethod + def _ssl_enabled() -> bool: + certs = BASE_DIR / "config" / "certs" + return (certs / "jarvis.key").exists() and (certs / "jarvis.crt").exists() + + def get_url(self) -> str: + proto = "https" if self._ssl_enabled() else "http" + return f"{proto}://{self._ip}:{PORT}" + + def get_manual_url(self) -> str: + """URL for manual browser entry. When HTTPS active, points to alias port (also HTTPS).""" + if self._ssl_enabled(): + return f"{self._ip}:{PORT + 1}" + return f"{self._ip}:{PORT}" + + def _aes_key(self, session_key: str) -> bytes: + if session_key not in self._aes_cache: + self._aes_cache[session_key] = _derive_key(session_key) + return self._aes_cache[session_key] + + def _decrypt(self, token: str, enc_b64: str) -> str | None: + sk = self._token_keys.get(token) + if not sk: + return None + try: + return _decrypt_cbc(self._aes_key(sk), enc_b64) + except Exception: + return None + + # ── callbacks ──────────────────────────────────────────────────────── + + def set_wake_callback(self, fn) -> None: + self._wake_callback = fn + + def set_connect_callback(self, fn) -> None: + self._connect_callback = fn + + # ── broadcast ──────────────────────────────────────────────────────── + + async def broadcast(self, msg: dict) -> None: + self._history.append(msg) + if len(self._history) > 300: + self._history = self._history[-300:] + dead: set[WebSocket] = set() + for ws in list(self._clients): + try: + await ws.send_json(msg) + except Exception: + dead.add(ws) + self._clients -= dead + + # ── FastAPI app ─────────────────────────────────────────────────────── + + def _build_app(self) -> "FastAPI": + app = FastAPI(docs_url=None, redoc_url=None) + + def _auth(req: Request) -> bool: + tok = req.headers.get("authorization", "").removeprefix("Bearer ").strip() + return bool(tok) and tok in self._tokens + + # serve CryptoJS from local cache, fallback to CDN redirect + @app.get("/static/crypto.js") + async def serve_crypto(): + if _CRYPTOJS_FILE.exists(): + return FileResponse(str(_CRYPTOJS_FILE), + media_type="application/javascript") + from fastapi.responses import RedirectResponse + return RedirectResponse(_CRYPTOJS_CDN) + + @app.get("/login", response_class=HTMLResponse) + async def login_page(): + return HTMLResponse(self._login_html) + + @app.get("/", response_class=HTMLResponse) + async def index(): + # Auth is handled client-side via sessionStorage bearer token. + # Server-side header auth can't work here because browser navigations + # don't send custom headers (location.href doesn't carry Authorization). + html = (self._app_html + .replace("__IP__", self._ip) + .replace("__PORT__", str(PORT))) + return HTMLResponse(html) + + @app.post("/login") + async def login(req: Request): + body = await req.json() + entered = str(body.get("pin", "")).strip().upper() + now = time.time() + if entered in self._pending_keys and self._pending_keys[entered] > now: + del self._pending_keys[entered] # one-time use + tok = secrets.token_urlsafe(32) + self._tokens.add(tok) + self._token_keys[tok] = entered + self._aes_key(entered) # pre-derive & cache + if self._connect_callback: + self._connect_callback() + asyncio.create_task(self.broadcast( + {"type": "sys", "text": "Remote connection established."} + )) + # Bearer token in response body — no cookies needed (works on any browser/HTTP) + return JSONResponse({"ok": True, "token": tok}) + return JSONResponse({"ok": False, "error": "Invalid or expired key"}, + status_code=401) + + @app.get("/auto-login") + async def auto_login(key: str = ""): + """QR code target — validates one-time key, creates session, redirects phone.""" + now = time.time() + if not key or key not in self._pending_keys or self._pending_keys[key] <= now: + return HTMLResponse(""" + + +Press Remote Control in JARVIS to get a new QR code.
+Connecting to JARVIS…
+""") + + @app.post("/api/device-login") + async def device_login_ep(req: Request): + """Return a fresh auth token for a previously paired device token.""" + try: + body = await req.json() + except Exception: + return JSONResponse({"ok": False}, status_code=400) + dev_tok = (body.get("device_token") or "").strip() + if not dev_tok or dev_tok not in self._device_sessions: + return JSONResponse({"ok": False}, status_code=401) + session_key = self._device_sessions[dev_tok]["session_key"] + tok = secrets.token_urlsafe(32) + self._tokens.add(tok) + self._token_keys[tok] = session_key + self._aes_key(session_key) + if self._connect_callback: + self._connect_callback() + asyncio.create_task(self.broadcast( + {"type": "sys", "text": "Known device reconnected automatically."} + )) + return JSONResponse({"ok": True, "token": tok, "key": session_key}) + + @app.post("/api/revoke-devices") + async def revoke_devices(req: Request): + """Invalidate all persistent device tokens (admin action).""" + if not _auth(req): + return JSONResponse({"error": "Unauthorized"}, status_code=401) + count = len(self._device_sessions) + self._device_sessions.clear() + return JSONResponse({"ok": True, "revoked": count}) + + @app.post("/api/command") + async def command(req: Request): + if not _auth(req): + return JSONResponse({"error": "Unauthorized"}, status_code=401) + body = await req.json() + token = req.headers.get("authorization", "").removeprefix("Bearer ").strip() + enc = body.get("enc", "") + if enc: + text = self._decrypt(token, enc) + if text is None: + return JSONResponse({"error": "Decryption failed"}, status_code=400) + else: + text = (body.get("text") or "").strip() + if text: + await self._command_queue.put(text) + if self._wake_callback: + self._wake_callback() + return JSONResponse({"ok": True}) + + @app.post("/api/wake") + async def wake_ep(req: Request): + if not _auth(req): + return JSONResponse({"error": "Unauthorized"}, status_code=401) + if self._wake_callback: + self._wake_callback() + return JSONResponse({"ok": True}) + + # ── Phone mic real-time audio → Gemini Live ────────────────────────── + + @app.websocket("/ws/phone-audio") + async def phone_audio_ws(websocket: WebSocket, token: str = ""): + tok = token.strip() + if not tok or tok not in self._tokens: + await websocket.close(code=4001) + return + await websocket.accept() + asyncio.create_task(self.broadcast( + {"type": "sys", "text": "Phone microphone live."} + )) + try: + while True: + data = await websocket.receive_bytes() + try: + self._phone_audio_queue.put_nowait( + {"data": data, "mime_type": "audio/pcm"} + ) + except asyncio.QueueFull: + pass # drop frame rather than block + except WebSocketDisconnect: + pass + finally: + asyncio.create_task(self.broadcast( + {"type": "sys", "text": "Phone microphone stopped."} + )) + + # ── File sharing ────────────────────────────────────────────────────── + + def _safe_filename(raw: str) -> str: + name = Path(raw).name # strip path components + name = re.sub(r'[<>:"/\\|?*\x00-\x1f]', '_', name).strip(". ") + return name or "upload" + + if _UPLOAD_OK: + @app.post("/api/upload") + async def upload_file(req: Request, file: UploadFile = FastAPIFile(...)): + if not _auth(req): + return JSONResponse({"error": "Unauthorized"}, status_code=401) + + safe = _safe_filename(file.filename or "upload") + dest = self._uploads_dir / safe + stem, suffix = Path(safe).stem, Path(safe).suffix + counter = 1 + while dest.exists(): + dest = self._uploads_dir / f"{stem}_{counter}{suffix}" + counter += 1 + + size = 0 + max_bytes = MAX_UPLOAD_MB * 1024 * 1024 + try: + with open(dest, "wb") as fout: + while True: + chunk = await file.read(65536) + if not chunk: + break + size += len(chunk) + if size > max_bytes: + fout.close() + dest.unlink(missing_ok=True) + return JSONResponse( + {"error": f"File too large (max {MAX_UPLOAD_MB} MB)"}, + status_code=413, + ) + fout.write(chunk) + except Exception as exc: + try: + dest.unlink(missing_ok=True) + except Exception: + pass + return JSONResponse({"error": str(exc)}, status_code=500) + + asyncio.create_task(self.broadcast({ + "type": "file_received", + "name": dest.name, + "size": size, + "saved_to": str(self._uploads_dir), + })) + return JSONResponse({"ok": True, "name": dest.name, "size": size}) + else: + @app.post("/api/upload") + async def upload_unavailable(req: Request): + return JSONResponse( + {"error": "File uploads require: pip install python-multipart"}, + status_code=503, + ) + + @app.get("/api/files") + async def list_files(req: Request): + if not _auth(req): + return JSONResponse({"error": "Unauthorized"}, status_code=401) + files = [] + try: + for f in sorted( + (p for p in self._uploads_dir.iterdir() if p.is_file()), + key=lambda p: p.stat().st_mtime, + reverse=True, + ): + files.append({"name": f.name, "size": f.stat().st_size}) + except Exception: + pass + return JSONResponse({"files": files}) + + @app.get("/uploads/{filename}") + async def download_file(filename: str, token: str = ""): + # Auth via query param — browser can't send custom headers + tok = token.strip() + if not tok or tok not in self._tokens: + return JSONResponse({"error": "Unauthorized"}, status_code=401) + safe = re.sub(r'[/\\]', '', filename) + path = self._uploads_dir / safe + if not path.exists() or not path.is_file(): + return JSONResponse({"error": "Not found"}, status_code=404) + return FileResponse(str(path), filename=safe) + + @app.websocket("/ws") + async def ws_ep(websocket: WebSocket, token: str = ""): + tok = token.strip() + if not tok or tok not in self._tokens: + await websocket.close(code=4001) + return + await websocket.accept() + self._clients.add(websocket) + for entry in self._history[-50:]: + try: + await websocket.send_json(entry) + except Exception: + break + try: + while True: + data = await websocket.receive_json() + if data.get("type") == "command": + enc = data.get("enc", "") + t = self._decrypt(tok, enc) if enc else (data.get("text") or "").strip() + if t: + await self._command_queue.put(t) + if self._wake_callback: + self._wake_callback() + except WebSocketDisconnect: + pass + finally: + self._clients.discard(websocket) + + return app + + # ── serve ───────────────────────────────────────────────────────────── + + async def _serve_alias(self) -> None: + """Second HTTPS server on PORT+1 sharing the same app and in-memory state. + Chrome HTTPS-upgrades any bare IP:PORT the user types, so this port also needs TLS. + User types IP:8001 → Chrome tries https → self-signed cert warning → accept once → done.""" + ssl_key = BASE_DIR / "config" / "certs" / "jarvis.key" + ssl_cert = BASE_DIR / "config" / "certs" / "jarvis.crt" + asyncio.get_event_loop().run_in_executor(None, _ensure_network_access, PORT + 1) + cfg = uvicorn.Config( + self.app, host="0.0.0.0", port=PORT + 1, log_level="warning", + ssl_keyfile=str(ssl_key), ssl_certfile=str(ssl_cert), + ) + print(f"[Dashboard] Manual entry: {self._ip}:{PORT + 1} (type in browser, accept cert once)") + await uvicorn.Server(cfg).serve() + + async def serve(self) -> None: + if not _DEPS_OK: + print("[Dashboard] fastapi/uvicorn not installed — dashboard disabled.") + print("[Dashboard] Run: pip install fastapi 'uvicorn[standard]' cryptography") + return + + # Firewall setup runs in a thread — uvicorn starts immediately, + # no waiting for UAC dialogs or subprocess timeouts. + asyncio.get_event_loop().run_in_executor(None, _ensure_network_access, PORT) + + use_ssl = self._ssl_enabled() + ssl_key = BASE_DIR / "config" / "certs" / "jarvis.key" + ssl_cert = BASE_DIR / "config" / "certs" / "jarvis.crt" + + if use_ssl: + asyncio.create_task(self._serve_alias()) + + cfg = uvicorn.Config( + self.app, host="0.0.0.0", port=PORT, log_level="warning", + **({"ssl_keyfile": str(ssl_key), "ssl_certfile": str(ssl_cert)} if use_ssl else {}), + ) + + proto = "https" if use_ssl else "http" + print(f"[Dashboard] {proto}://{self._ip}:{PORT}") + print("[Dashboard] Press 'Remote Control' in JARVIS UI to get the QR code.") + await uvicorn.Server(cfg).serve() diff --git a/main.py b/main.py index 6a09363..32b58b1 100644 --- a/main.py +++ b/main.py @@ -1,22 +1,6 @@ import platform as _platform import subprocess as _subprocess -# ── Console encoding ───────────────────────────────────────────────────────── -# Windows consoles default to a legacy codepage — cp1254 in Turkey, cp1251 in -# Russia, cp932 in Japan. Printing an emoji there raises UnicodeEncodeError, and -# several of these prints sit inside except handlers, so the handler itself dies -# and skips the recovery code after it. Reconfiguring to UTF-8 with a -# replacement fallback costs nothing and makes the app behave in every locale. -import sys as _sys - -for _stream in ("stdout", "stderr"): - try: - _s = getattr(_sys, _stream, None) - if _s is not None and hasattr(_s, "reconfigure"): - _s.reconfigure(encoding="utf-8", errors="replace") - except Exception: - pass # pythonw / redirected pipes / anything exotic — never fatal - # ── Nuclear: force CREATE_NO_WINDOW on EVERY subprocess call on Windows ─────── # This patches Popen itself, so no per-file flag is needed anywhere. if _platform.system() == "Windows": @@ -32,6 +16,24 @@ if _platform.system() == "Windows": # ───────────────────────────────────────────────────────────────────────────── +# ── Console encoding ───────────────────────────────────────────────────────── +# Status lines in this app carry emoji and arrows ("📤 file_controller → Moved: +# a.txt → Documents/"). On a non-UTF-8 console — cp1254 on a Turkish Windows, +# cp1251 on a Russian one, cp932 on a Japanese one — printing one of those +# raises UnicodeEncodeError, and because the print sits after the tool's own +# try/except, the exception escapes into the receive loop and takes the session +# down. The assistant dies on a log line. +# +# Reconfiguring costs nothing and makes the app start the same way in every +# locale. `errors="replace"` is the belt and braces — a console that genuinely +# cannot render a glyph shows a box instead of killing the process. +import sys as _sys +for _stream in (_sys.stdout, _sys.stderr): + try: + _stream.reconfigure(encoding="utf-8", errors="replace") + except Exception: + pass + import asyncio import re import threading @@ -53,23 +55,13 @@ from memory.memory_manager import ( search_memory, set_trim_notifier, ) -from actions.file_processor import file_processor -from actions.flight_finder import flight_finder -from actions.open_app import open_app -from actions.weather_report import weather_action -from actions.send_message import send_message -from actions.reminder import reminder -from actions.computer_settings import computer_settings +# The file-backed tools (open_app, web_search, browser_control, …) are no longer +# imported or declared here — they self-describe via a TOOL dict in their own +# actions/*.py file and are auto-discovered by core.action_loader at startup. +# Only tools that are tied to live-session state stay inline in this file +# (screen_process, close_camera, save_memory, manage_monitor, shutdown_jarvis, +# system_status). from actions.screen_processor import _capture_camera, _capture_screen -from actions.youtube_video import youtube_video -from actions.desktop import desktop_control -from actions.browser_control import browser_control -from actions.file_controller import file_controller -from actions.code_helper import code_helper -from actions.dev_agent import dev_agent -from actions.web_search import web_search as web_search_action -from actions.computer_control import computer_control -from actions.game_updater import game_updater from actions.system_monitor import SystemMonitor, get_system_status from actions.proactive import ProactiveEngine from actions.background_monitor import ( @@ -77,12 +69,20 @@ from actions.background_monitor import ( ) from actions.web_search import _news as _fetch_news_sync from memory.config_manager import ( - get_brief_enabled, get_voice, get_input_device, get_output_device, + get_brief_enabled, get_voice, get_wake_word_enabled, save_wake_word_enabled, get_input_device, get_output_device, ) from core.plugin_loader import discover_plugins from core import undo as undo_stack from core import confirm as confirm_gate from core import audio_devices +from core.action_loader import discover_actions +from core.wake_word import ( + WakeWordDetector, is_ready as wake_is_ready, install_and_download as wake_install, +) + +# How long the assistant stays awake with no user speech before it auto-sleeps +# again (wake-word mode only). +WAKE_SLEEP_TIMEOUT = 120.0 # seconds (2 minutes) def get_base_dir(): if getattr(sys, "frozen", False): @@ -92,9 +92,9 @@ def get_base_dir(): BASE_DIR = get_base_dir() API_CONFIG_PATH = BASE_DIR / "config" / "api_keys.json" PROMPT_PATH = BASE_DIR / "core" / "prompt.txt" -LIVE_MODEL = "models/gemini-2.5-flash-native-audio-preview-12-2025" +LIVE_MODEL = "models/gemini-3.1-flash-live-preview" CHANNELS = 1 -SEND_SAMPLE_RATE = 16000 +SEND_SAMPLE_RATE = 16000 RECEIVE_SAMPLE_RATE = 24000 CHUNK_SIZE = 1024 @@ -143,44 +143,12 @@ def _clean_transcript(text: str) -> str: return text.strip() TOOL_DECLARATIONS = [ - { - "name": "open_app", - "description": ( - "Opens any application on the computer. " - "Use this whenever the user asks to open, launch, or start any app, " - "website, or program. Always call this tool — never just say you opened it." - ), - "parameters": { - "type": "OBJECT", - "properties": { - "app_name": { - "type": "STRING", - "description": "Exact name of the application (e.g. 'WhatsApp', 'Chrome', 'Spotify')" - } - }, - "required": ["app_name"] - } - }, - { - "name": "web_search", - "description": ( - "Searches the web. Use for ANY question about current facts, events, prices, " - "or topics — always prefer this over guessing. " - "Modes: 'search' (default), 'news' (latest headlines on a topic), " - "'research' (deep comprehensive answer), 'price' (product cost lookup), " - "'compare' (side-by-side comparison of items)." - ), - "parameters": { - "type": "OBJECT", - "properties": { - "query": {"type": "STRING", "description": "Search query or topic"}, - "mode": {"type": "STRING", "description": "search | news | research | price | compare"}, - "items": {"type": "ARRAY", "items": {"type": "STRING"}, "description": "Items to compare (compare mode)"}, - "aspect": {"type": "STRING", "description": "Comparison aspect: price | specs | reviews | features"}, - }, - "required": ["query"] - } - }, + # ── Inline tools ───────────────────────────────────────────────────────── + # These stay here (rather than in an actions/*.py TOOL dict) because their + # handling is woven into live-session state — vision capture/injection, + # camera stream, memory writes, the monitor engine, and shutdown. All other + # tools live in their own action file and are auto-discovered by + # core.action_loader (see JarvisLive.__init__). { "name": "system_status", "description": ( @@ -193,61 +161,6 @@ TOOL_DECLARATIONS = [ "properties": {}, } }, - { - "name": "weather_report", - "description": "Gives the weather report to user", - "parameters": { - "type": "OBJECT", - "properties": { - "city": {"type": "STRING", "description": "City name"} - }, - "required": ["city"] - } - }, - { - "name": "send_message", - "description": "Sends a text message via WhatsApp, Telegram, or other messaging platform.", - "parameters": { - "type": "OBJECT", - "properties": { - "receiver": {"type": "STRING", "description": "Recipient contact name"}, - "message_text": {"type": "STRING", "description": "The message to send"}, - "platform": {"type": "STRING", "description": "Platform: WhatsApp, Telegram, etc."} - }, - "required": ["receiver", "message_text", "platform"] - } - }, - { - "name": "reminder", - "description": "Sets a timed reminder using Task Scheduler.", - "parameters": { - "type": "OBJECT", - "properties": { - "date": {"type": "STRING", "description": "Date in YYYY-MM-DD format"}, - "time": {"type": "STRING", "description": "Time in HH:MM format (24h)"}, - "message": {"type": "STRING", "description": "Reminder message text"} - }, - "required": ["date", "time", "message"] - } - }, - { - "name": "youtube_video", - "description": ( - "Controls YouTube. Use for: playing videos, summarizing a video's content, " - "getting video info, or showing trending videos." - ), - "parameters": { - "type": "OBJECT", - "properties": { - "action": {"type": "STRING", "description": "play | summarize | get_info | trending (default: play)"}, - "query": {"type": "STRING", "description": "Search query for play action"}, - "save": {"type": "BOOLEAN", "description": "Save summary to Notepad (summarize only)"}, - "region": {"type": "STRING", "description": "Country code for trending e.g. TR, US"}, - "url": {"type": "STRING", "description": "Video URL for get_info action"}, - }, - "required": [] - } - }, { "name": "screen_process", "description": ( @@ -271,225 +184,11 @@ TOOL_DECLARATIONS = [ "name": "close_camera", "description": ( "Closes the live camera view shown on screen. " - "Call when user says: close camera, stop camera, turn off camera, " - "kamerayı kapat, kapat, creepy, etc." + "Call when the user says (in ANY language): close camera, stop camera, " + "turn off camera, that's creepy, etc." ), "parameters": {"type": "OBJECT", "properties": {}, "required": []} }, - { - "name": "computer_settings", - "description": ( - "Controls the computer: volume, brightness, window management, keyboard shortcuts, " - "typing text on screen, closing apps, fullscreen, dark mode, WiFi, restart, shutdown, " - "scrolling, tab management, zoom, screenshots, lock screen, refresh/reload page. " - "Use for ANY single computer control command. " - "restart, shutdown and toggle_wifi put a confirmation on the user's screen " - "and do NOT happen until they press it — never claim they are done. " - "Volume, brightness and dark mode can be reversed with the `undo` tool." - ), - "parameters": { - "type": "OBJECT", - "properties": { - # The exact vocabulary, spelled out. - # - # This used to say only "The action to perform", so the model - # usually filled `description` instead — and computer_settings - # then made a SECOND Gemini call, inside the tool, purely to - # translate that sentence into one of these names. Every - # "turn the volume down" cost two model round trips. - "action": { - "type": "STRING", - "description": ( - "The exact action. Prefer this over `description` — pick one of: " - "volume_up | volume_down | volume_set | mute | " - "brightness_up | brightness_down | sleep_display | " - "pause_video | close_app | close_window | full_screen | " - "minimize | maximize | snap_left | snap_right | " - "switch_window | show_desktop | task_manager | focus_search | " - "refresh_page | close_tab | new_tab | next_tab | prev_tab | " - "go_back | go_forward | zoom_in | zoom_out | zoom_reset | " - "find_on_page | scroll_up | scroll_down | scroll_top | " - "scroll_bottom | page_up | page_down | copy | paste | cut | " - "undo | redo | select_all | save | enter | escape | press_key | " - "type_text | screenshot | lock_screen | open_settings | " - "file_explorer | open_run | dark_mode | toggle_wifi | " - "restart | shutdown" - ), - }, - "description": { - "type": "STRING", - "description": ( - "Fallback only, when no action name above fits. " - "Resolved locally — no extra model call." - ), - }, - "value": {"type": "STRING", "description": "Optional value: volume level 0-100, text to type, key name, etc."} - }, - "required": [] - } - }, - { - "name": "browser_control", - "description": ( - "Controls any web browser. Use for: opening websites, searching the web, " - "clicking elements, filling forms, scrolling, screenshots, navigation, any web-based task. " - "Simple open/search requests launch the user's own browser normally (their real profile " - "and logged-in accounts); interactive actions (click, type, fill_form...) attach an " - "automation browser. " - "Always pass the 'browser' parameter when the user specifies a browser (e.g. 'open in Edge', " - "'use Firefox', 'open Chrome'). Multiple browsers can run simultaneously." - ), - "parameters": { - "type": "OBJECT", - "properties": { - "action": {"type": "STRING", "description": "go_to | search | click | type | scroll | fill_form | smart_click | smart_type | get_text | get_url | press | new_tab | close_tab | screenshot | back | forward | reload | switch | list_browsers | close | close_all"}, - "browser": {"type": "STRING", "description": "Target browser: chrome | edge | firefox | opera | operagx | brave | vivaldi | safari. Omit to use the currently active browser."}, - "url": {"type": "STRING", "description": "URL for go_to / new_tab action"}, - "query": {"type": "STRING", "description": "Search query for search action"}, - "engine": {"type": "STRING", "description": "Search engine: google | bing | duckduckgo | yandex (default: google)"}, - "selector": {"type": "STRING", "description": "CSS selector for click/type"}, - "text": {"type": "STRING", "description": "Text to click or type"}, - "description": {"type": "STRING", "description": "Element description for smart_click/smart_type"}, - "direction": {"type": "STRING", "description": "up | down for scroll"}, - "amount": {"type": "INTEGER", "description": "Scroll amount in pixels (default: 500)"}, - "key": {"type": "STRING", "description": "Key name for press action (e.g. Enter, Escape, F5)"}, - "path": {"type": "STRING", "description": "Save path for screenshot"}, - "incognito": {"type": "BOOLEAN", "description": "Open in private/incognito mode"}, - "clear_first": {"type": "BOOLEAN", "description": "Clear field before typing (default: true)"}, - }, - "required": ["action"] - } - }, - { - "name": "file_controller", - "description": "Manages files and folders: list, create, delete, move, copy, rename, read, write, find, disk usage.", - "parameters": { - "type": "OBJECT", - "properties": { - "action": {"type": "STRING", "description": "list | create_file | create_folder | delete | move | copy | rename | read | write | find | largest | disk_usage | organize_desktop | info"}, - "path": {"type": "STRING", "description": "File/folder path or shortcut: desktop, downloads, documents, home"}, - "destination": {"type": "STRING", "description": "Destination path for move/copy"}, - "new_name": {"type": "STRING", "description": "New name for rename"}, - "content": {"type": "STRING", "description": "Content for create_file/write"}, - "name": {"type": "STRING", "description": "File name to search for"}, - "extension": {"type": "STRING", "description": "File extension to search (e.g. .pdf)"}, - "count": {"type": "INTEGER", "description": "Number of results for largest"}, - }, - "required": ["action"] - } - }, - { - "name": "desktop_control", - "description": "Controls the desktop: wallpaper, organize, clean, list, stats.", - "parameters": { - "type": "OBJECT", - "properties": { - "action": {"type": "STRING", "description": "wallpaper | wallpaper_url | organize | clean | list | stats | task"}, - "path": {"type": "STRING", "description": "Image path for wallpaper"}, - "url": {"type": "STRING", "description": "Image URL for wallpaper_url"}, - "mode": {"type": "STRING", "description": "by_type or by_date for organize"}, - "task": {"type": "STRING", "description": "Natural language desktop task"}, - }, - "required": ["action"] - } - }, - { - "name": "code_helper", - "description": "Writes, edits, explains, runs, or builds code files.", - "parameters": { - "type": "OBJECT", - "properties": { - "action": {"type": "STRING", "description": "write | edit | explain | run | build | auto (default: auto)"}, - "description": {"type": "STRING", "description": "What the code should do or what change to make"}, - "language": {"type": "STRING", "description": "Programming language (default: python)"}, - "output_path": {"type": "STRING", "description": "Where to save the file"}, - "file_path": {"type": "STRING", "description": "Path to existing file for edit/explain/run/build"}, - "code": {"type": "STRING", "description": "Raw code string for explain"}, - "args": {"type": "STRING", "description": "CLI arguments for run/build"}, - "timeout": {"type": "INTEGER", "description": "Execution timeout in seconds (default: 30)"}, - }, - "required": ["action"] - } - }, - { - "name": "dev_agent", - "description": "Builds complete multi-file projects from scratch: plans, writes files, installs deps, opens VSCode, runs and fixes errors.", - "parameters": { - "type": "OBJECT", - "properties": { - "description": {"type": "STRING", "description": "What the project should do"}, - "language": {"type": "STRING", "description": "Programming language (default: python)"}, - "project_name": {"type": "STRING", "description": "Optional project folder name"}, - "timeout": {"type": "INTEGER", "description": "Run timeout in seconds (default: 30)"}, - }, - "required": ["description"] - } - }, - { - "name": "computer_control", - "description": "Direct computer control: type, click, hotkeys, scroll, move mouse, screenshots, find elements on screen.", - "parameters": { - "type": "OBJECT", - "properties": { - "action": {"type": "STRING", "description": "type | smart_type | click | double_click | right_click | hotkey | press | scroll | move | copy | paste | screenshot | wait | clear_field | focus_window | screen_find | screen_click | random_data | user_data"}, - "text": {"type": "STRING", "description": "Text to type or paste"}, - "x": {"type": "INTEGER", "description": "X coordinate"}, - "y": {"type": "INTEGER", "description": "Y coordinate"}, - "keys": {"type": "STRING", "description": "Key combination e.g. 'ctrl+c'"}, - "key": {"type": "STRING", "description": "Single key e.g. 'enter'"}, - "direction": {"type": "STRING", "description": "up | down | left | right"}, - "amount": {"type": "INTEGER", "description": "Scroll amount (default: 3)"}, - "seconds": {"type": "NUMBER", "description": "Seconds to wait"}, - "title": {"type": "STRING", "description": "Window title for focus_window"}, - "description": {"type": "STRING", "description": "Element description for screen_find/screen_click"}, - "type": {"type": "STRING", "description": "Data type for random_data"}, - "field": {"type": "STRING", "description": "Field for user_data: name|email|city"}, - "clear_first": {"type": "BOOLEAN", "description": "Clear field before typing (default: true)"}, - "path": {"type": "STRING", "description": "Save path for screenshot"}, - }, - "required": ["action"] - } - }, - { - "name": "game_updater", - "description": ( - "THE ONLY tool for ANY Steam or Epic Games request. " - "Use for: installing, downloading, updating games, listing installed games, " - "checking download status, scheduling updates. " - "ALWAYS call directly for any Steam/Epic/game request. " - "NEVER use browser_control or web_search for Steam/Epic." - ), - "parameters": { - "type": "OBJECT", - "properties": { - "action": {"type": "STRING", "description": "update | install | list | download_status | schedule | cancel_schedule | schedule_status (default: update)"}, - "platform": {"type": "STRING", "description": "steam | epic | both (default: both)"}, - "game_name": {"type": "STRING", "description": "Game name (partial match supported)"}, - "app_id": {"type": "STRING", "description": "Steam AppID for install (optional)"}, - "hour": {"type": "INTEGER", "description": "Hour for scheduled update 0-23 (default: 3)"}, - "minute": {"type": "INTEGER", "description": "Minute for scheduled update 0-59 (default: 0)"}, - "shutdown_when_done": {"type": "BOOLEAN", "description": "Shut down PC when download finishes"}, - }, - "required": [] - } - }, - { - "name": "flight_finder", - "description": "Searches Google Flights and speaks the best options.", - "parameters": { - "type": "OBJECT", - "properties": { - "origin": {"type": "STRING", "description": "Departure city or airport code"}, - "destination": {"type": "STRING", "description": "Arrival city or airport code"}, - "date": {"type": "STRING", "description": "Departure date (any format)"}, - "return_date": {"type": "STRING", "description": "Return date for round trips"}, - "passengers": {"type": "INTEGER", "description": "Number of passengers (default: 1)"}, - "cabin": {"type": "STRING", "description": "economy | premium | business | first"}, - "save": {"type": "BOOLEAN", "description": "Save results to Notepad"}, - }, - "required": ["origin", "destination", "date"] - } - }, { "name": "manage_monitor", "description": ( @@ -528,72 +227,6 @@ TOOL_DECLARATIONS = [ "properties": {}, } }, - { - "name": "file_processor", - "description": ( - "Processes any file that the user has uploaded or dropped onto the interface. " - "Use this when the user refers to an uploaded file and wants an action on it. " - "Supports: images (describe/ocr/resize/compress/convert), " - "PDFs (summarize/extract_text/to_word), " - "Word docs & text files (summarize/fix/reformat/translate), " - "CSV/Excel (analyze/stats/filter/sort/convert), " - "JSON/XML (validate/format/analyze), " - "code files (explain/review/fix/optimize/run/document/test), " - "audio (transcribe/trim/convert/info), " - "video (trim/extract_audio/extract_frame/compress/transcribe/info), " - "archives (list/extract), " - "presentations (summarize/extract_text). " - "ALWAYS call this tool when a file has been uploaded and the user gives a command about it. " - "If the user's command is ambiguous, pick the most logical action for that file type." - ), - "parameters": { - "type": "OBJECT", - "properties": { - "file_path": { - "type": "STRING", - "description": "Full path to the uploaded file. Leave empty to use the currently uploaded file." - }, - "action": { - "type": "STRING", - "description": ( - "What to do with the file. Examples by type:\n" - "image: describe | ocr | resize | compress | convert | info\n" - "pdf: summarize | extract_text | to_word | info\n" - "docx/txt: summarize | fix | reformat | translate_hint | word_count | to_bullet\n" - "csv/excel: analyze | stats | filter | sort | convert | info\n" - "json: validate | format | analyze | to_csv\n" - "code: explain | review | fix | optimize | run | document | test\n" - "audio: transcribe | trim | convert | info\n" - "video: trim | extract_audio | extract_frame | compress | transcribe | info | convert\n" - "archive: list | extract\n" - "pptx: summarize | extract_text | analyze" - ) - }, - "instruction": { - "type": "STRING", - "description": "Free-form instruction if action doesn't cover it. E.g. 'translate this to Turkish', 'find all email addresses'" - }, - "format": { - "type": "STRING", - "description": "Target format for conversion. E.g. 'mp3', 'pdf', 'csv', 'png'" - }, - "width": {"type": "INTEGER", "description": "Target width for image resize"}, - "height": {"type": "INTEGER", "description": "Target height for image resize"}, - "scale": {"type": "NUMBER", "description": "Scale factor for image resize (e.g. 0.5)"}, - "quality": {"type": "INTEGER", "description": "Quality 1-100 for image/video compress"}, - "start": {"type": "STRING", "description": "Start time for trim: seconds or HH:MM:SS"}, - "end": {"type": "STRING", "description": "End time for trim: seconds or HH:MM:SS"}, - "timestamp": {"type": "STRING", "description": "Timestamp for video frame extraction HH:MM:SS"}, - "column": {"type": "STRING", "description": "Column name for CSV filter/sort"}, - "value": {"type": "STRING", "description": "Filter value for CSV filter"}, - "condition": {"type": "STRING", "description": "Filter condition: equals|contains|gt|lt"}, - "ascending": {"type": "BOOLEAN", "description": "Sort order for CSV sort (default: true)"}, - "save": {"type": "BOOLEAN", "description": "Save result to file (default: true)"}, - "destination": {"type": "STRING", "description": "Output folder for archive extract"}, - }, - "required": [] - } -}, { "name": "save_memory", "description": ( @@ -717,14 +350,13 @@ def _keep_context_of(exc: BaseException) -> bool: class JarvisLive: - def __init__(self, ui: JarvisUI): self.ui = ui - self._asst_name = "JARVIS" # updated each session from config + self._asst_name = "JARVI S" # updated each session from config self.session = None self.audio_in_queue = None self.out_queue = None - self._loop = None + self._loop = None self._is_speaking = False self._speaking_lock = threading.Lock() self._phone_active = False # True while phone mic is streaming; pauses PC mic @@ -765,16 +397,130 @@ class JarvisLive: self._last_user_speech = time.monotonic() # updated on every user utterance self._session_log: list[str] = [] # conversation turns for end-of-session summary - self._enhanced_live = True # affective dialog + proactive audio; auto-disabled if the server rejects them - _core_names = {t["name"] for t in TOOL_DECLARATIONS} + self._enhanced_live = True # proactive audio; auto-disabled if the server rejects it + + _base_dir = Path(__file__).resolve().parent + _inline_names = {t["name"] for t in TOOL_DECLARATIONS} + + # File-backed tools: every actions/*.py with a TOOL dict, discovered the + # same way plugins are. Reserved names = the inline tools above, so an + # action can never shadow one. + self._action_registry = discover_actions( + actions_dir=_base_dir / "actions", + reserved_names=_inline_names, + logger=lambda msg: print(f"[Actions] {msg}"), + ) + + # Plugins must not collide with either an inline tool or a discovered action. + _core_names = _inline_names | self._action_registry.names() self._plugin_registry = discover_plugins( - plugins_dir=Path(__file__).resolve().parent / "plugins", + plugins_dir=_base_dir / "plugins", core_tool_names=_core_names, logger=lambda msg: (print(f"[Plugins] {msg}"), self.ui.write_log(f"SYS: {msg}")), ) self.ui.get_plugins = self._plugin_registry.list_for_ui + self.ui.get_plugin_settings = self._plugin_registry.settings_schemas # ⚙ settings tab self.ui.request_say = self.plugin_say # plugins: mid-task speech channel + # ── Wake word ──────────────────────────────────────────────────────── + # _awake gates the mic (see _listen_audio) and the background speakers. + # It is True whenever wake word is OFF, so default behaviour is unchanged. + self._wake_enabled = get_wake_word_enabled() + self._awake = not self._wake_enabled + self._wake_detector: WakeWordDetector | None = None + self._wake_sleep_timeout = WAKE_SLEEP_TIMEOUT + # UI control surface for the Wake Word settings section. + self.ui.wake_is_ready = wake_is_ready # () -> bool + self.ui.wake_get_state = self._wake_state # () -> dict + self.ui.on_wake_toggle = self._ui_wake_toggle # (enable: bool) -> str + self.ui.on_wake_manual = self._ui_wake_manual # () -> toggle awake/asleep + self.ui.on_wake_install = self._ui_wake_install # () -> (ok, msg) + + # ── Wake word: state machine ───────────────────────────────────────────── + + def _wake_state(self) -> dict: + # A loaded, running detector is definitively ready; otherwise fall back + # to the cheap on-disk model-file check (no Model construction). + ready = bool(self._wake_detector and self._wake_detector.ready) or wake_is_ready() + return {"enabled": self._wake_enabled, "awake": self._awake, "ready": ready} + + def _ensure_wake_detector(self) -> bool: + """Load the detector once (model loads on first start). Idempotent.""" + if self._wake_detector is None: + self._wake_detector = WakeWordDetector( + on_detect=self._on_wake_detected, + logger=lambda m: (print(f"[Wake] {m}"), self.ui.write_log(f"SYS: {m}")), + ) + if not self._wake_detector.ready: + return self._wake_detector.start() + return True + + def _on_wake_detected(self) -> None: + """Called from the detector thread when 'Hey Jarvis' is heard.""" + self.wake(reason="wake word") + + def wake(self, reason: str = "wake word") -> None: + if self._awake: + return + self._awake = True + self._last_user_speech = time.monotonic() # start the auto-sleep clock now + if not self.ui.muted: + self.ui.set_state("LISTENING") + self.ui.write_log(f"SYS: Awake — {reason}.") + + def sleep(self, reason: str = "timeout") -> None: + if not self._awake: + return + self._awake = False + self.set_speaking(False) + self.ui.set_state("SLEEPING") + self.ui.write_log(f"SYS: Sleeping — {reason}. Say 'Hey Jarvis' to wake me.") + + async def _run_sleep_watch(self) -> None: + """Auto-sleep after the configured silence window (wake-word mode only).""" + while True: + await asyncio.sleep(5) + if not self._wake_enabled or not self._awake: + continue + with self._speaking_lock: + speaking = self._is_speaking + if speaking: + continue + if (time.monotonic() - self._last_user_speech) > self._wake_sleep_timeout: + self.sleep(reason="no speech for 2 minutes") + + # ── Wake word: UI callbacks (called from the Qt thread) ────────────────── + + def _ui_wake_toggle(self, enable: bool) -> str: + """Enable/disable wake word from the settings UI. Returns a status token: + 'enabled' | 'disabled' | 'need_download'.""" + if enable: + if not wake_is_ready(): + return "need_download" + self._wake_enabled = True + save_wake_word_enabled(True) + self._ensure_wake_detector() + self.sleep(reason="wake word enabled") + return "enabled" + else: + self._wake_enabled = False + save_wake_word_enabled(False) + self.wake(reason="wake word disabled") + return "disabled" + + def _ui_wake_manual(self) -> None: + """Manual sleep/wake button in the UI.""" + if not self._wake_enabled: + return + if self._awake: + self.sleep(reason="you tapped sleep") + else: + self.wake(reason="you tapped wake") + + def _ui_wake_install(self) -> tuple[bool, str]: + """Download openwakeword + the model (runs in a UI worker thread).""" + return wake_install(logger=lambda m: self.ui.write_log(f"SYS: {m}")) + def plugin_say(self, instruction: str) -> None: """ Thread-safe speech channel for plugins: lets a plugin ask JARVIS to @@ -791,7 +537,7 @@ class JarvisLive: async def _say(): try: await self.session.send_client_content( - turns={"parts": [{"text": instruction}]}, + turns={"role": "user", "parts": [{"text": instruction}]}, turn_complete=True, ) except Exception as e: @@ -865,9 +611,15 @@ class JarvisLive: def _on_text_command(self, text: str): if not self._loop or not self.session: return + # Respect wake-word sleep: a typed command must not be answered while + # asleep either (the sleep gate is not just for the mic). Wake first with + # "Hey Jarvis" or the WAKE NOW button. + if self._wake_enabled and not self._awake: + self.ui.write_log("SYS: I'm asleep — say 'Hey Jarvis' or tap WAKE NOW first.") + return asyncio.run_coroutine_threadsafe( self.session.send_client_content( - turns={"parts": [{"text": text}]}, + turns={"role": "user", "parts": [{"text": text}]}, turn_complete=True ), self._loop @@ -905,7 +657,7 @@ class JarvisLive: return asyncio.run_coroutine_threadsafe( self.session.send_client_content( - turns={"parts": [{"text": text}]}, + turns={"role": "user", "parts": [{"text": text}]}, turn_complete=True ), self._loop @@ -966,7 +718,11 @@ class JarvisLive: output_audio_transcription={}, input_audio_transcription={}, system_instruction="\n".join(parts), - tools=[{"function_declarations": TOOL_DECLARATIONS + self._plugin_registry.get_tool_declarations()}], + tools=[{"function_declarations": ( + TOOL_DECLARATIONS + + self._action_registry.get_tool_declarations() + + self._plugin_registry.get_tool_declarations() + )}], # Hand back the handle captured from the last session_resumption # update. `handle=None` is exactly the old behaviour (ask for # handles, start fresh), so the first connect of a run is unchanged. @@ -987,10 +743,12 @@ class JarvisLive: ), ) if self._enhanced_live: - # Affective dialog: JARVIS hears tone/emotion and adapts its voice. # Proactive audio: JARVIS stays silent when speech isn't addressed # to it (background chatter, talking to someone else in the room). - cfg["enable_affective_dialog"] = True + # (Affective dialog was dropped: gemini-3.1-flash-live does not + # support it, and it never reliably detected tone in practice. + # To restore it on a 2.5 native-audio model, add back: + # cfg["enable_affective_dialog"] = True ) cfg["proactivity"] = types.ProactivityConfig(proactive_audio=True) return types.LiveConnectConfig(**cfg) @@ -1035,34 +793,6 @@ class JarvisLive: else: result = await loop.run_in_executor(None, undo_stack.undo_last) - elif name == "open_app": - r = await loop.run_in_executor(None, lambda: open_app(parameters=args, response=None, player=self.ui)) - result = r or f"Opened {args.get('app_name')}." - - elif name == "weather_report": - r = await loop.run_in_executor(None, lambda: weather_action(parameters=args, player=self.ui)) - result = r or "Weather delivered." - - elif name == "browser_control": - r = await loop.run_in_executor(None, lambda: browser_control(parameters=args, player=self.ui)) - result = r or "Done." - - elif name == "file_controller": - r = await loop.run_in_executor(None, lambda: file_controller(parameters=args, player=self.ui)) - result = r or "Done." - - elif name == "send_message": - r = await loop.run_in_executor(None, lambda: send_message(parameters=args, response=None, player=self.ui, session_memory=None)) - result = r or f"Message sent to {args.get('receiver')}." - - elif name == "reminder": - r = await loop.run_in_executor(None, lambda: reminder(parameters=args, response=None, player=self.ui)) - result = r or "Reminder set." - - elif name == "youtube_video": - r = await loop.run_in_executor(None, lambda: youtube_video(parameters=args, response=None, player=self.ui)) - result = r or "Done." - elif name == "screen_process": import time as _t_mod _now = _t_mod.monotonic() @@ -1098,52 +828,6 @@ class JarvisLive: self.ui.stop_camera_stream() result = "Camera closed." - elif name == "computer_settings": - r = await loop.run_in_executor(None, lambda: computer_settings(parameters=args, response=None, player=self.ui)) - result = r or "Done." - - elif name == "desktop_control": - r = await loop.run_in_executor(None, lambda: desktop_control(parameters=args, player=self.ui)) - result = r or "Done." - - elif name == "code_helper": - r = await loop.run_in_executor(None, lambda: code_helper(parameters=args, player=self.ui, speak=self.speak)) - result = r or "Done." - - elif name == "dev_agent": - r = await loop.run_in_executor(None, lambda: dev_agent(parameters=args, player=self.ui, speak=self.speak)) - result = r or "Done." - - elif name == "web_search": - r = await loop.run_in_executor(None, lambda: web_search_action(parameters=args, player=self.ui)) - result = r or "Done." - # Mirror results to the on-screen content panel - _mode = args.get("mode", "search") - if r and not r.startswith("No results") and not r.startswith("Search failed"): - _query = args.get("query") or ", ".join(args.get("items", [])) - _label = f"{_mode.upper()} — {_query[:38]}" if _query else _mode.upper() - self.ui.show_content(_label, r) - elif name == "file_processor": - if not args.get("file_path") and self.ui.current_file: - args["file_path"] = self.ui.current_file - r = await loop.run_in_executor( - None, - lambda: file_processor(parameters=args, player=self.ui, speak=self.speak) - ) - result = r or "Done." - - elif name == "computer_control": - r = await loop.run_in_executor(None, lambda: computer_control(parameters=args, player=self.ui)) - result = r or "Done." - - elif name == "game_updater": - r = await loop.run_in_executor(None, lambda: game_updater(parameters=args, player=self.ui, speak=self.speak)) - result = r or "Done." - - elif name == "flight_finder": - r = await loop.run_in_executor(None, lambda: flight_finder(parameters=args, player=self.ui)) - result = r or "Done." - elif name == "system_status": r = await loop.run_in_executor(None, get_system_status) result = str(r) @@ -1168,7 +852,7 @@ class JarvisLive: if self.session: try: await self.session.send_client_content( - turns={"parts": [{"text": "Say a brief natural goodbye to the user."}]}, + turns={"role": "user", "parts": [{"text": "Say a brief natural goodbye to the user."}]}, turn_complete=True, ) except Exception: @@ -1178,6 +862,23 @@ class JarvisLive: _os._exit(0) asyncio.create_task(_do_shutdown()) + elif self._action_registry.has(name): + # file_processor: fall back to the currently-uploaded file when none is given + if name == "file_processor" and not args.get("file_path") and self.ui.current_file: + args["file_path"] = self.ui.current_file + _ctx = {"player": self.ui, "speak": self.speak, + "response": None, "session_memory": None} + r = await loop.run_in_executor(None, lambda: self._action_registry.run(name, args, _ctx)) + result = r or "Done." + # web_search: mirror results to the on-screen content panel + if (name == "web_search" and r + and not r.startswith("No results") + and not r.startswith("Search failed")): + _mode = args.get("mode", "search") + _query = args.get("query") or ", ".join(args.get("items", [])) + _label = f"{_mode.upper()} — {_query[:38]}" if _query else _mode.upper() + self.ui.show_content(_label, r) + else: if self._plugin_registry.has(name): r = await loop.run_in_executor( @@ -1205,13 +906,35 @@ class JarvisLive: async def _send_realtime(self): while True: msg = await self.out_queue.get() - await self.session.send_realtime_input(media=msg) + # Gemini 3.x Live rejects the old realtime_input.media_chunks field + # (what `media=...` maps to) and closes the socket with a 1007. Send + # mic / phone PCM through the new `audio` field instead. Queue items + # are {"data":