--- /dev/null
+/.idea/
+/.settings/
+/target/
+/*.iml
+/*.log
+/test/
+
+/doc/apidocs/
+/doc/graphs/
+/doc/index.html
+/doc/setup.html
+
+
--- /dev/null
+Creative Commons Legal Code
+
+CC0 1.0 Universal
+
+ CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE
+ LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN
+ ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS
+ INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES
+ REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS
+ PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM
+ THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED
+ HEREUNDER.
+
+Statement of Purpose
+
+The laws of most jurisdictions throughout the world automatically confer
+exclusive Copyright and Related Rights (defined below) upon the creator
+and subsequent owner(s) (each and all, an "owner") of an original work of
+authorship and/or a database (each, a "Work").
+
+Certain owners wish to permanently relinquish those rights to a Work for
+the purpose of contributing to a commons of creative, cultural and
+scientific works ("Commons") that the public can reliably and without fear
+of later claims of infringement build upon, modify, incorporate in other
+works, reuse and redistribute as freely as possible in any form whatsoever
+and for any purposes, including without limitation commercial purposes.
+These owners may contribute to the Commons to promote the ideal of a free
+culture and the further production of creative, cultural and scientific
+works, or to gain reputation or greater distribution for their Work in
+part through the use and efforts of others.
+
+For these and/or other purposes and motivations, and without any
+expectation of additional consideration or compensation, the person
+associating CC0 with a Work (the "Affirmer"), to the extent that he or she
+is an owner of Copyright and Related Rights in the Work, voluntarily
+elects to apply CC0 to the Work and publicly distribute the Work under its
+terms, with knowledge of his or her Copyright and Related Rights in the
+Work and the meaning and intended legal effect of CC0 on those rights.
+
+1. Copyright and Related Rights. A Work made available under CC0 may be
+protected by copyright and related or neighboring rights ("Copyright and
+Related Rights"). Copyright and Related Rights include, but are not
+limited to, the following:
+
+ i. the right to reproduce, adapt, distribute, perform, display,
+ communicate, and translate a Work;
+ ii. moral rights retained by the original author(s) and/or performer(s);
+iii. publicity and privacy rights pertaining to a person's image or
+ likeness depicted in a Work;
+ iv. rights protecting against unfair competition in regards to a Work,
+ subject to the limitations in paragraph 4(a), below;
+ v. rights protecting the extraction, dissemination, use and reuse of data
+ in a Work;
+ vi. database rights (such as those arising under Directive 96/9/EC of the
+ European Parliament and of the Council of 11 March 1996 on the legal
+ protection of databases, and under any national implementation
+ thereof, including any amended or successor version of such
+ directive); and
+vii. other similar, equivalent or corresponding rights throughout the
+ world based on applicable law or treaty, and any national
+ implementations thereof.
+
+2. Waiver. To the greatest extent permitted by, but not in contravention
+of, applicable law, Affirmer hereby overtly, fully, permanently,
+irrevocably and unconditionally waives, abandons, and surrenders all of
+Affirmer's Copyright and Related Rights and associated claims and causes
+of action, whether now known or unknown (including existing as well as
+future claims and causes of action), in the Work (i) in all territories
+worldwide, (ii) for the maximum duration provided by applicable law or
+treaty (including future time extensions), (iii) in any current or future
+medium and for any number of copies, and (iv) for any purpose whatsoever,
+including without limitation commercial, advertising or promotional
+purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each
+member of the public at large and to the detriment of Affirmer's heirs and
+successors, fully intending that such Waiver shall not be subject to
+revocation, rescission, cancellation, termination, or any other legal or
+equitable action to disrupt the quiet enjoyment of the Work by the public
+as contemplated by Affirmer's express Statement of Purpose.
+
+3. Public License Fallback. Should any part of the Waiver for any reason
+be judged legally invalid or ineffective under applicable law, then the
+Waiver shall be preserved to the maximum extent permitted taking into
+account Affirmer's express Statement of Purpose. In addition, to the
+extent the Waiver is so judged Affirmer hereby grants to each affected
+person a royalty-free, non transferable, non sublicensable, non exclusive,
+irrevocable and unconditional license to exercise Affirmer's Copyright and
+Related Rights in the Work (i) in all territories worldwide, (ii) for the
+maximum duration provided by applicable law or treaty (including future
+time extensions), (iii) in any current or future medium and for any number
+of copies, and (iv) for any purpose whatsoever, including without
+limitation commercial, advertising or promotional purposes (the
+"License"). The License shall be deemed effective as of the date CC0 was
+applied by Affirmer to the Work. Should any part of the License for any
+reason be judged legally invalid or ineffective under applicable law, such
+partial invalidity or ineffectiveness shall not invalidate the remainder
+of the License, and in such case Affirmer hereby affirms that he or she
+will not (i) exercise any of his or her remaining Copyright and Related
+Rights in the Work or (ii) assert any associated claims and causes of
+action with respect to the Work, in either case contrary to Affirmer's
+express Statement of Purpose.
+
+4. Limitations and Disclaimers.
+
+ a. No trademark or patent rights held by Affirmer are waived, abandoned,
+ surrendered, licensed or otherwise affected by this document.
+ b. Affirmer offers the Work as-is and makes no representations or
+ warranties of any kind concerning the Work, express, implied,
+ statutory or otherwise, including without limitation warranties of
+ title, merchantability, fitness for a particular purpose, non
+ infringement, or the absence of latent or other defects, accuracy, or
+ the present or absence of errors, whether or not discoverable, all to
+ the greatest extent permissible under applicable law.
+ c. Affirmer disclaims responsibility for clearing rights of other persons
+ that may apply to the Work or any use thereof, including without
+ limitation any person's Copyright and Related Rights in the Work.
+ Further, Affirmer disclaims responsibility for obtaining any necessary
+ consents, permissions or other rights required for any use of the
+ Work.
+ d. Affirmer understands and acknowledges that Creative Commons is not a
+ party to this document and has no duty or obligation with respect to
+ this CC0 or use of the Work.
--- /dev/null
+#!/bin/bash
+
+set -f
+
+java -Xmx4500m -classpath /opt/alyverkko-cli/* eu.svjatoslav.alyverkko_cli.Main "$@"
+
--- /dev/null
+task_directory: "/home/user/AI/tasks"
+models_directory: "/home/user/AI/models"
+default_temperature: 0.7
+llama_cpp_dir_path: "/home/user/AI/llama.cpp/"
+batch_thread_count: 10
+thread_count: 6
+skills_directory: "/home/user/.config/alyverkko-cli/skills"
+models:
+ - alias: "default"
+ filesystem_path: "WizardLM-2-8x22B.Q5_K_M-00001-of-00005.gguf"
+ context_size_tokens: 64000
+ end_of_text_marker: null
+ - alias: "mistral"
+ filesystem_path: "Mistral-Large-Instruct-2407.Q8_0.gguf"
+ context_size_tokens: 32768
+ end_of_text_marker: null
--- /dev/null
+#+SETUPFILE: ~/.emacs.d/org-styles/html/darksun.theme
+#+TITLE: Älyverkko CLI application
+#+LANGUAGE: en
+#+LATEX_HEADER: \usepackage[margin=1.0in]{geometry}
+#+LATEX_HEADER: \usepackage{parskip}
+#+LATEX_HEADER: \usepackage[none]{hyphenat}
+
+#+OPTIONS: H:20 num:20
+#+OPTIONS: author:nil
+
+* Introduction
+
+The *Älyverkko CLI* application is a user-friendly tool developed in
+Java, specifically tailored to streamline the utilization of expansive
+language models through CPU-based computation in batch processing
+mode.
+
+To illustrate its capabilities: Imagine harnessing the power of a vast
+language model, boasting approximately 100 billion parameters, solely
+relying on CPU computations and leveraging the open-source software
+*llama.cpp*. This setup requires a modern consumer-grade CPU and
+approximately 128 GB of RAM. To put this into perspective, 128 GB of
+RAM is financially comparable to purchasing a high-quality smartphone,
+making it an economical option for many users.
+
+In contrast, executing the same model on an Nvidia GPU could result in
+costs that are significantly higher, potentially at least by an order
+of magnitude.
+
+However, there is a trade-off: CPU-based processing for such extensive
+models is inherently slow. This means real-time interaction, like
+chatting with the AI, wouldn't be practical or enjoyable due to the
+lag in response times. Nevertheless, when deployed in a
+non-interactive batch mode, this "slow but smart" AI can complete
+numerous valuable tasks within a 24-hour window. For instance, it
+could generate a substantial amount of code, potentially exceeding
+what you could thoroughly review in the same timeframe. Additionally,
+it could process more documents than most individuals would be
+inclined to read manually.
+
+The primary objective of the *Älyverkko CLI* project is to identify
+and enable applications where this "slow but smart" AI can excel. By
+utilizing *llama.cpp* as its inference engine, the project aims to
+unlock a variety of uses where batch processing is more advantageous
+than real-time interaction.
+
+Here are some practical examples of tasks suited for the
+*Älyverkko CLI* application:
+
+- [[id:e1b6aa9a-a27d-4019-8bd4-fceb0c606e23][Domain: Natural Language Processing (NLP)]]
+- [[id:f4c7d3e0-8c18-4123-a797-871ca73a7580][Domain: Code Generation]]
+- [[id:f38360ad-54f6-4f24-b299-f73a9faacabd][Domain: Content Creation]]
+
+In summary, the *Älyverkko CLI* application opens up a realm of
+possibilities for leveraging powerful AI in scenarios where immediate
+responses are not critical, but high-quality batch processing output
+is highly beneficial.
+
+Note: project is still in early stage.
+
+** Use cases
+*** Domain: Natural Language Processing (NLP)
+:PROPERTIES:
+:ID: e1b6aa9a-a27d-4019-8bd4-fceb0c606e23
+:END:
+
+*Problem Statement:*
+
+Analyze a set of customer reviews to determine overall sentiment and
+extract key features that customers appreciate or dislike about a
+product.
+
+*Usage Procedure:*
+1. User collects customer reviews in plain text format within the
+ configured mail directory. Lets say, about 150 kilobytes of reviews
+ per input file (this is dictated by AI model available context
+ size).
+2. Each review file is prefixed with "TOCOMPUTE:".
+3. The Älyverkko CLI application processes these files, generating
+ sentiment analysis results and feature extraction insights.
+4. Results are appended to the original files in org-mode syntax,
+ indicating AI responses.
+
+*** Domain: Code Generation
+:PROPERTIES:
+:ID: f4c7d3e0-8c18-4123-a797-871ca73a7580
+:END:
+
+*Problem Statement:*
+
+Generate code snippets for a new software module based on detailed
+specifications provided by the developer.
+
+*Usage Procedure:*
+
+1. Developer writes specifications in a text file within the mail
+ directory, prefixed with "TOCOMPUTE:". Text file also contains
+ relevant parts of the program source code and
+ documentation. Älyverkko CLI *joinfiles* subcommand can be used to
+ facilitate such text file preparation.
+
+2. The Älyverkko CLI application processes this file and generates the
+ corresponding code snippets. The generated code is appended to the
+ original specifications file, organized using org-mode syntax.
+
+3. Developer can review proposed changes and then integrate them back
+ into original program source code.
+
+Note: Most of the [[https://www3.svjatoslav.eu/projects/alyverkko-cli/][Älyverkko CLI]] program code is written in such a way
+by AI. [[https://www2.svjatoslav.eu/gitweb/?p=alyverkko-cli.git;a=blob;f=tools/implement+idea;h=02b0ceb260693a6c9733f221e52a0e6c5fce0a36;hb=HEAD][This script]] is used to facilitate the process.
+
+*** Domain: Content Creation
+:PROPERTIES:
+:ID: f38360ad-54f6-4f24-b299-f73a9faacabd
+:END:
+
+*Problem Statement:*
+
+Draft an outline for a book on science fiction and improve its plot.
+
+*Usage Procedure:*
+
+1. The book author writes a brief describing the outline of the plot
+ and his book main idea for the novel.
+
+2. *Älyverkko CLI* processes this description and generates an outline
+ with suggested headings and suggests possible improvements to the
+ plot.
+
+Here is [[https://www.svjatoslav.eu/writing/Whispers%20in%20the%20Stream%20of%20Time.html][example sci-fi book]] that was written with the help of
+*Älyverkko CLI*.
+
+* Getting started
+
+When you first encounter Älyverkko CLI, the setup process might seem
+involved compared to cloud-based AI services. That's completely
+understandable! Let me walk you through why each step exists and how
+it ultimately creates a powerful, private, and cost-effective AI
+assistant that works /for you/.
+
+** Why Bother With This Setup? (The Big Picture)
+
+Before diving into steps, let's address the elephant in the room: *Why
+go through this setup when ChatGPT is just a click away?*
+
+Because Älyverkko CLI solves a fundamentally different problem:
+
+- ✨ *Privacy by design*: All processing happens on your machine - no
+ data ever leaves your computer
+- 💰 *Cost efficiency*: Run 70B+ parameter models without paying per
+ token (128GB RAM ≈ cost of a smartphone)
+- ⚙️ *Full control*: Tweak every parameter to match your specific needs
+- 📦 *Offline capability*: Works without internet connection
+- 🕒 *Batch processing*: Perfect for "set it and forget it" workflows
+ while you sleep
+
+This isn't designed for real-time chatting (CPU inference is slow),
+but for substantial tasks where quality matters more than speed: code
+generation, document analysis, content creation, etc.
+
+** Your Setup Journey - What to Expect
+
+Here's what you'll be doing, explained simply with /why/ each step
+matters:
+
+*** 1. Installing Java & Maven (The Foundation)
+
+- *What*: Install JDK 21+ and Apache Maven
+- *Why*: Älyverkko CLI is written in Java - these tools let you build
+ and run the application.
+ - *Don't worry*: On Debian/Ubuntu, it's just
+ : sudo apt install openjdk-21-jdk maven
+
+*Key insight*: Java was chosen because it's cross-platform,
+memory-safe, and perfect for long-running background processes like
+our AI task processor.
+
+*** 2. Building llama.cpp (Your AI Engine)
+
+- *What*: Download and compile the llama.cpp project from GitHub.
+- *Why*: This is the actual "brain" that runs large language models on
+ *your CPU*. We build from source (rather than using prebuilt
+ binaries) so it can optimize for /your specific CPU/ - squeezing out
+ *maximum performance from your hardware.
+
+*** 3. Adding AI Models (The Brains)
+- *What*: Download GGUF format model files (typically 4-30GB each)
+- *Where*: From Hugging Face Hub ([[https://huggingface.co/models?search=gguf][search "GGUF"]]).
+- *Why*: These contain the actual neural networks that power the AI.
+- *Don't worry*: Start with one model (like Mistral 7B) - you can add
+ more later.
+- *Key insight*: GGUF format was created specifically for CPU
+ inference.
+
+#+begin_quote
+❓ Why not smaller models? Larger models (even running slowly on CPU)
+produce significantly better results for complex tasks - it's worth
+the wait.
+#+end_quote
+
+*** 4. Running the Interactive Wizard (=alyverkko-cli wizard=)
+- *What*: Launch the configuration wizard that asks simple questions.
+- *Why*: To connect all the pieces without you needing to edit complex
+ YAML files.
+- *Don't worry*: It's interactive! You'll answer questions like "Where did
+ you put your AI models?" with easy prompts.
+- *Key insight*: This creates your personal
+ =~/.config/alyverkko-cli.yaml= file.
+
+#+begin_quote
+🌟 Pro tip: The wizard automatically detects your models and suggests
+reasonable defaults - you're not starting from scratch.
+#+end_quote
+
+*** 5. Setting Up "Skills" (Your Custom Instructions)
+
+- *What*: Create simple YAML files defining how the AI should behave
+ for different tasks.
+- *Why*: So you don't have to rewrite instructions every time ("be a
+ coding assistant" vs "be a writing editor").
+- *Don't worry*: Sample skills come pre-configured - you can modify
+ them gradually.
+- *Key insight*: Skills let you create specialized AI personas without
+ changing models.
+
+#+begin_quote
+Example: Your =writer.yaml= skill might instruct the AI to "always
+provide well-reasoned responses in academic tone"
+#+end_quote
+
+*** 6. Preparing Your First Task (The Magic Moment)
+- *What*: Create a text file with your request, prefixed with
+ *TOCOMPUTE:*
+- *Why*: This triggers the background processing system
+- *Key insight*: File-based interaction isn't primitive - it's
+ intentional design for batch processing.
+
+** Why Files Instead of a Fancy UI?
+
+You might wonder: /Why deal with text files when everything has
+beautiful interfaces these days?/
+
+Because *this is designed for productivity, not conversation*:
+
+1. *No waiting around*: With CPU inference, responses take
+ minutes/hours. File-based workflow lets you queue tasks and get
+ back to work.
+2. *Natural integration*: Works with your existing text editor (VS
+ Code, Emacs, etc.) rather than forcing a new interface.
+3. *Version control friendly*: You can track changes to
+ prompts/responses with Git.
+4. *Resource efficient*: No heavy GUI consuming precious RAM needed
+ for AI models.
+5. *Scriptable*: Easily integrate with other tools in your workflow.
+
+Think of it like email versus phone calls - sometimes asynchronous
+communication is actually /more/ productive.
+
+** The Light at the End of the Tunnel
+
+After initial setup (which typically takes 30-60 minutes), here's what
+you get:
+
+- ✅ A silent background process that automatically processes tasks
+- ✅ Complete privacy - no data ever leaves your machine
+- ✅ The ability to run state-of-the-art models without expensive
+ hardware.
+- ✅ A system that keeps working while you sleep - queue up 10 tasks
+ before bed, get results in the morning.
+
+You fill find that after the first few processed tasks, the initial
+setup effort feels worthwhile. You're not just getting another
+chatbot - you're building a personal AI workstation tailored to your
+specific needs. The initial investment pays dividends every time you
+need serious AI power without compromise.
+
+* Installation
+** Requirements
+*Operating System:*
+
+Älyverkko CLI is developed and tested on Debian 12 "Bookworm". It
+should work on any modern Linux distribution with minimal adjustments
+to the installation process.
+
+*Dependencies:*
+- Java Development Kit (JDK) 17 or higher
+- Apache Maven for building the project
+
+*Hardware Requirements:*
+- Modern multi-core CPU.
+- The more RAM you have, the smarter AI model you can use. For
+ example, at least 64 GB of RAM is needed to run decent AI models
+ with sufficiently large context.
+- Sufficient disk space to store large language models and
+ input/output data.
+
+** Installation
+:PROPERTIES:
+:ID: 0b705a37-9b84-4cd5-878a-fedc9ab09b12
+:END:
+At the moment, to use Älyverkko CLI, you need to:
+- Download sources and build [[https://github.com/ggerganov/llama.cpp][llama.cpp]] project.
+- Download [[id:f5740953-079b-40f4-87d8-b6d1635a8d39][sources]] and build Älyverkko CLI project.
+- Download one or more pre-trained large language models in GGUF
+ format. Hugging Face repository [[https://huggingface.co/models?search=GGUF][has lot of them]].
+
+Follow instructions for obtaining and building Älyverkko CLI on your
+computer that runs Debian 13 operating system:
+
+1. Ensure that you have Java Development Kit (JDK) installed on your
+ system.
+ : sudo apt-get install openjdk-21-jdk
+
+2. Ensure that you have Apache Maven installed:
+ : sudo apt-get install maven
+
+3. Clone the [[id:f5740953-079b-40f4-87d8-b6d1635a8d39][code repository]] or download the [[id:f5740953-079b-40f4-87d8-b6d1635a8d39][source code]] for the
+ `alyverkko-cli` application to your local machine.
+
+4. Navigate to the root directory of the cloned/downloaded project in
+ your terminal.
+
+5. Execute the installation script by running
+ : ./install
+
+ This script will compile the application and install it to
+ directory
+ : /opt/alyverkko-cli
+
+ To facilitate usage from command-line, it will also define
+ system-wide command *alyverkko-cli* as well as "Älyverkko CLI"
+ launcher in desktop applications menu.
+
+6. Prepare Älyverkko CLI [[id:0fcdae48-81c5-4ae1-bdb9-64ae74e87c45][configuration]] file.
+
+7. Verify that the application has been installed correctly by running
+ *alyverkko-cli* in your terminal.
+
+** Alyverkko CLI daemon configuration
+:PROPERTIES:
+:ID: 0fcdae48-81c5-4ae1-bdb9-64ae74e87c45
+:END:
+Älyverkko CLI application configuration is done by editing YAML
+formatted configuration file.
+
+Configuration file should be placed under current user home directory:
+: ~/.config/alyverkko-cli.yaml
+
+*** Key Parameters Explained
+**** Core Directories
+
+- =mail_directory=: Where task files are placed for processing
+- =models_directory=: Contains GGUF model files
+- =skills_directory=: Contains YAML skill definition files
+- =llama_cli_path=: Path to llama.cpp's executable
+
+**** Generation Parameters
+
+- =default_temperature=: Creativity control (0-3, higher = more
+ creative)
+- =default_top_p=: Nucleus sampling threshold (0.0-1., higher = more
+ diverse)
+- =default_repeat_penalty=: Penalty for repetition (>0.0, 1.0 = no
+ penalty)
+
+**** Performance Tuning
+
+- =thread_count=: Sets the number of threads to be used by the AI
+ during response generation. RAM data transfer speed is usually
+ bottleneck here. When RAM bandwidth is saturated, increasing thread
+ count will no longer increase processing speed, but it will still
+ keep CPU cores unnecessarily busy.
+
+- =batch_thread_count=: Specifies the number of threads to use for
+ input prompt processing. CPU computing power is usually the
+ bottleneck here.
+
+**** Model-Specific Settings
+
+Each model in the =models= list can have:
+
+- =alias=: Short model alias. Model with alias "default" would be used
+ by default.
+
+- =filesystem_path=: File name of the model as located within
+ *models_directory*
+
+- =context_size_tokens=: Context size in tokens that model was trained
+ on.
+
+- =end_of_text_marker=: Some models produce certain markers to
+ indicate end of their output. If specified here, Älyverkko CLI can
+ identify and remove them so that they don't leak into
+ conversation. Default value is: *null*.
+
+- =temperature=, =top_p=, =repeat_penalty=: Model-specific overrides
+
+*** Configuration file example
+
+The application is configured using a YAML-formatted configuration
+file. Below is an example of how the configuration file might look:
+
+#+begin_src yaml
+ task_directory: "/home/user/AI/tasks"
+ models_directory: "/home/user/AI/models"
+ skills_directory: "/home/user/AI/skills"
+ llama_cli_path: "/home/user/AI/llama.cpp/build/bin/llama-cli"
+
+ # Processing parameters
+ default_temperature: 0.7
+ default_top_p: 0.9
+ default_repeat_penalty: 1.0
+ thread_count: 6
+ batch_thread_count: 10
+
+ # Model definitions
+ models:
+ - alias: "default"
+ filesystem_path: "model.gguf"
+ context_size_tokens: 64000
+ end_of_text_marker: null
+ temperature: 0.8 # Optional model-specific parameter
+ top_p: 0.9 # Optional
+ repeat_penalty: 1.1 # Optional
+ - alias: "mistral"
+ filesystem_path: "Mistral-Large-Instruct-2407.Q8_0.gguf"
+ context_size_tokens: 32768
+ end_of_text_marker: null
+#+end_src
+
+*** Parameter Precedence Hierarchy
+
+For *temperature*, *top_p*, and *repeat_penalty* parameters, values
+are determined using this priority order (highest to lowest):
+
+1. *Skill-specific value* (from skill's YAML file)
+2. *Model-specific value* (from model configuration)
+3. *Global default value* (from main configuration)
+
+This allows fine-grained control where more specific configurations
+override broader ones.
+
+*** Enlisting available models
+Once Älyverkko CLI is installed and properly configured, you can run
+following command at commandline to see what models are available to
+it:
+
+: alyverkko-cli listmodels
+
+Note: Models that reference missing files will be automatically marked
+with "-missing" suffix in their alias by configuration wizard. You can
+manually remove this suffix after fixing the model file path.
+
+*** Self test
+The *selftest* command performs a series of checks to ensure the
+system is configured correctly:
+
+: alyverkko-cli selftest
+
+It verifies:
+- Configuration file integrity.
+- Model directory existence.
+- The presence of the *llama.cpp* executable.
+
+** Skill concept and configuration
+*** Skill File Format
+
+Skills are defined in YAML files stored in the *skills_directory*.
+
+Each skill file contains:
+
+#+begin_src yaml
+prompt: "Full system prompt text here"
+temperature: 0.8 # Optional
+top_p: 0.9 # Optional
+repeat_penalty: 1.1 # Optional
+#+end_src
+
+The system prompt must contain *<TASK-FILE>* which gets replaced with
+the actual user prompt during execution.
+
+*** Example Skill File
+: writer.yaml
+
+#+begin_src yaml
+ temperature: 0.9
+ top_p: 0.95
+ prompt: |
+ <|im_start|>system
+ User will provide you with task that needs to be solved along with
+ existing relevant information.
+
+ You are artificial general intelligence system that always provides well reasoned responses.
+ <|im_end|>
+ <|im_start|>user
+ /think Solve following problem:
+
+ <TASK-FILE>
+
+ <|im_end|>
+ <|im_start|>assistant
+#+end_src
+
+** Starting process daemon
+
+Älyverkko CLI keeps continuously listening for and processing tasks
+from a specified mail directory.
+
+There are multiple alternative ways to start Älyverkko CLI in mail
+processing mode:
+
+**** Start via command line interface
+
+1. Open your terminal.
+
+2. Run the command:
+ : alyverkko-cli process
+
+3. The application will start monitoring the configured mail directory
+ for incoming messages and process them accordingly in endless loop.
+
+4. To terminate Älyverkko CLI, just hit *CTRL+c* on the keyboard, or
+ close terminal window.
+
+**** Start using your desktop environment application launcher
+
+1. Access the application launcher or application menu on your desktop
+ environment.
+
+2. Search for "Älyverkko CLI".
+
+3. Click on the icon to start the application. It will open its own
+ terminal.
+
+4. If you want to stop Älyverkko CLI, just close terminal window.
+
+**** Start in the background as systemd system service
+
+During Älyverkko CLI [[id:0b705a37-9b84-4cd5-878a-fedc9ab09b12][installation]], installation script will prompt you
+if you want to install *systemd* service. If you chose *Y*, Alyverkko
+CLI would be immediately started in the background as a system
+service. Also it will be automatically started on every system reboot.
+
+To view service status, use:
+: systemctl -l status alyverkko-cli
+
+If you want to stop or disable service, you can do so using systemd
+facilities:
+
+: sudo systemctl stop alyverkko-cli
+: sudo systemctl disable alyverkko-cli
+
+* Usage
+** Task file format
+
+Task files follow a specific structure that begins with a header line:
+
+#+begin_example
+TOCOMPUTE: [parameters]
+[User prompt content]
+#+end_example
+
+*** Task File Header Format
+
+The first line *must* begin with exactly =TOCOMPUTE:= followed by
+space-separated key-value pairs:
+
+#+begin_example
+TOCOMPUTE: skill=default model=mistral priority=10
+#+end_example
+
+Valid parameters in the header:
+- =skill=[name]=: Specifies which skill to use (defaults to "default")
+- =model=[alias]=: Specifies which AI model to use (defaults to "default")
+- =priority=[integer]=: Higher integers mean higher priority (default: 0)
+
+*** Processed File Format
+
+After AI processing completes, a new file is created with:
+1. First line: =DONE: skill=[name] model=[alias] duration=[time]=
+2. =* USER:= section containing original user prompt
+3. =* ASSISTANT:= section containing AI response
+
+: DONE: skill=writer model=default duration=5m
+: ...
+
+** Task preparation
+:PROPERTIES:
+:ID: 4b7900e4-77c1-45e7-9c54-772d0d3892ea
+:END:
+
+The Älyverkko CLI application expects input files for processing in
+the form of plain text files within the specified tasks directory
+(configured in the [[id:0fcdae48-81c5-4ae1-bdb9-64ae74e87c45][YAML configuration file]]).
+
+Suggested usage flow is to prepare AI assignments within the Älyverkko
+CLI mail directory using normal text editor. Once AI assignment is
+ready for processing, you should [[id:883d6e7c-60e0-422b-8c00-5cdc9dfec20d][initiate AI processing]] on that file.
+
+*** "joinfiles" command
+*Note:* See also alternative solution with similar goal: [[https://github.com/aerugo/prelude][prelude]].
+
+The *joinfiles* command is a utility for aggregating the contents of
+multiple files into a single document, which can then be processed by
+AI. This is particularly useful for preparing comprehensive problem
+statements from various source files, such as software project
+directories or collections of text documents.
+
+**** Usage
+
+To use the *joinfiles* command, specify the source directory
+containing the files you wish to join and a topic name that will be
+used to generate the output file name:
+
+#+begin_example
+alyverkko-cli joinfiles -s /path/to/source/directory -t "my_topic"
+#+end_example
+
+If desired, you can also specify a glob pattern to match only certain files within the directory:
+
+#+begin_example
+alyverkko-cli joinfiles -s /path/to/source/directory -p "*.java" -t "my_topic"
+#+end_example
+
+After joining the files, you can choose to open the resulting document
+in text editor for further editing or review:
+
+#+begin_example
+alyverkko-cli joinfiles -t "my_topic" --edit
+#+end_example
+
+**** Options
+
+- **-s, --src-dir**: Specifies the source directory from which to join
+ files.
+
+- **-p, --pattern**: An optional glob pattern to match specific files
+ within the source directory.
+
+- **-t, --topic**: The topic name that will be used as a basis for the
+ output file name and should reflect the subject matter of the joined
+ content.
+
+- **-e, --edit**: Opens the joined file in text editor after the join
+ operation is complete.
+
+**** Example Use Case
+
+Imagine you have a software project with various source files that you
+want to analyze using AI. You can use the *joinfiles* command to
+create a single document for processing:
+
+#+begin_example
+alyverkko-cli joinfiles -s /path/to/project -p "*.java" -t "software_analysis" --edit
+#+end_example
+
+This will recursively search the project directory for Java source
+files, aggregate their contents into a file named
+*software_analysis.org* (within AI processor input files directory),
+and open text editor on the file, so that you can add your analysis
+instructions or problem statement. Finally you [[id:883d6e7c-60e0-422b-8c00-5cdc9dfec20d][Initiate AI processing]]
+and after some time, you will get results and the end of the file.
+
+** Initiate AI processing
+:PROPERTIES:
+:ID: 883d6e7c-60e0-422b-8c00-5cdc9dfec20d
+:END:
+
+Once your task file is prepared, you should place *TOCOMPUTE:* marker
+on the first line of that file, so that it will be considered for
+processing.
+
+When the Älyverkko CLI detects a new or modified file in the mail
+directory:
+
+1. It checks if file has "TOCOMPUTE:" on the first line. If no, file
+ is ignored. Otherwise Älyverkko CLI continues processing the file.
+
+2. It reads the content of the file and feeds it as an input for an AI
+ model to generate a response.
+
+4. Once the AI has generated a response, the application appends this
+ response to the original mail contents within the same file, using
+ org-mode syntax to distinguish between the user's query and the
+ assistant's reply. The updated file will contain both the original
+ query (prefixed with: "* USER:*") and the AI's response (prefixed
+ with "* ASSISTANT:"), ensuring a clear and organized conversation
+ thread. "TOCOMPUTE:" is removed from the beginning of the file to
+ avoid processing same file again.
+
+Note: During AI task file preparation, feel free to save intermediary
+states as often as needed because AI engine will keep ignoring the
+file until *TOCOMPUTE:* line appears. Once AI assignment is ready, add
+: TOCOMPUTE:
+to the beginning of the file and save one last time. Älyverkko CLI
+will detect new task approximately within one second after file is
+saved and will start processing it.
+
+If your text editor automatically reloads file when it was changed by
+other process in the filesystem, AI response will appear within text
+editor as soon as AI response is ready. If needed, you can add further
+queries at the end of the file and re-add "TOCOMPUTE:" at the
+beginning of the file. This way AI will process file again and file
+becomes stateful conversation. If you use GNU Emacs text editor, you
+can benefit from [[id:25038854-c905-4b26-9670-cca06600223e][purpose-built GNU Emacs utilities]].
+
+** Helpful GNU Emacs utilities
+:PROPERTIES:
+:ID: 25038854-c905-4b26-9670-cca06600223e
+:END:
+
+Note: GNU Emacs and following Emacs Lisp utilities are not required to
+use Älyverkko CLI. Their purpose is to increase comfort for existing
+GNU Emacs users.
+
+*** Easily compose new problem statement for AI from emacs
+
+The Elisp function *ai-new-topic* facilitates the creation and opening
+of a new Org-mode file dedicated to a user-defined topic within a
+specified directory. Now you can use this file within emacs to compose
+you problem statement to AI.
+
+When *ai-new-topic* function triggered, it first prompts the user to
+input a topic name. This name will serve as the basis for the filename
+and the title within the document.
+
+The function then constructs a file path by concatenating the
+pre-defined =alyverkko-topic-files-directory= (which should be set to
+your topics directory), the topic name, and the =.org= extension. If a
+file with this path does not already exist, the function will create a
+new file and open it for editing.
+
+#+begin_src elisp :results none
+
+ (defvar alyverkko-task-files-directory "/home/user/my-ai-tasks-directory/"
+ "Directory where task files are stored.")
+
+ (defun alyverkko-new-task ()
+ "Create and open a task file in the specified directory."
+ (interactive)
+ (let ((task (read-string "Enter task name: ")))
+ (let ((file-path (concat alyverkko-task-files-directory task ".org")))
+ (if (not (file-exists-p file-path))
+ (with-temp-file file-path
+ ))
+ (find-file file-path)
+ (goto-char (point-max))
+ (org-mode))))
+
+#+end_src
+
+*** Easily signal to AI that problem statement is ready for solving
+
+The function =alyverkko-compute= is designed to enhance the workflow
+of users working with the Älyverkko CLI application by automating the
+process of marking text files for computation with a specific AI model
+and prompt.
+
+When function is invoked, it detects available prompts and allows user
+to select one.
+
+Thereafter function detects available models from Älyverkko CLI
+configuration file and allows user to select one.
+
+Finally function inserts at the beginning of currently opened file
+something like this:
+
+: TOCOMPUTE: prompt=<chosen-prompt> model=<chosen-model> priority=<chosen-priority>
+
+- Adjust *prompt-dir* variable to point to your prompts directory.
+- Adjust *config-file* variable to point to your Älyverkko CLI
+ configuration file path.
+
+#+begin_src emacs-lisp
+
+(defun alyverkko-compute ()
+ "Interactively pick a skill, model, and priority, then insert a
+TOCOMPUTE line at the top of the current buffer.
+
+Adjust `skill-dir` and `config-file` to match your setup."
+ (interactive)
+ (let ((skill-dir "~/.config/alyverkko-cli/skills/")
+ (config-file "~/.config/alyverkko-cli/alyverkko-cli.yaml")
+ models)
+
+ ;; Harvest model aliases from the Älyverkko CLI config
+ (with-temp-buffer
+ (insert-file-contents config-file)
+ (goto-char (point-min))
+ (when (search-forward-regexp "^models:" nil t)
+ (while (search-forward-regexp "^\\s-+- alias: \"\\([^\"]+\\)\"" nil t)
+ (push (match-string 1) models))))
+
+ (if (file-exists-p skill-dir)
+ (let* ((files (directory-files skill-dir t "\\`[^.].*\\.yaml\\'"))
+ (aliases (mapcar #'file-name-base files)))
+ (if aliases
+ (let* ((selected-alias (completing-read "Select skill: " aliases))
+ (model (completing-read "Select AI model: " models))
+ (priority (number-to-string
+ (read-number "Priority (integer, default 0): " 0))))
+ (alyverkko-insert-tocompute-line selected-alias model priority))
+ (message "No skill files found.")))
+ (message "Skill directory not found."))))
+
+(defun alyverkko-insert-tocompute-line (skill-alias model &optional priority)
+ "Insert a TOCOMPUTE line with SKILL-ALIAS, MODEL, and PRIORITY at
+the top of the current buffer."
+ (save-excursion
+ (goto-char (point-min))
+ (insert (format "TOCOMPUTE: skill=%s model=%s priority=%s\n"
+ skill-alias model (or priority "0")))
+ (save-buffer)))
+
+#+end_src
+
+* Getting the source code
+- This program is free software: released under Creative Commons Zero
+ (CC0) license.
+
+- Program author:
+ - Svjatoslav Agejenko
+ - Homepage: https://svjatoslav.eu
+ - Email: mailto://svjatoslav@svjatoslav.eu
+
+- [[https://www.svjatoslav.eu/projects/][Other software projects hosted at svjatoslav.eu]]
+
+** Source code
+:PROPERTIES:
+:ID: f5740953-079b-40f4-87d8-b6d1635a8d39
+:END:
+- [[https://www2.svjatoslav.eu/gitweb/?p=alyverkko-cli.git;a=snapshot;h=HEAD;sf=tgz][Download latest snapshot in TAR GZ format]]
+
+- [[https://www2.svjatoslav.eu/gitweb/?p=alyverkko-cli.git;a=summary][Browse Git repository online]]
+
+- Clone Git repository using command:
+ : git clone https://www3.svjatoslav.eu/git/alyverkko-cli.git
+
+- See [[https://www3.svjatoslav.eu/projects/alyverkko-cli/apidocs/][JavaDoc]].
+
+- See [[https://www3.svjatoslav.eu/projects/alyverkko-cli/graphs/][automatically generated class diagrams]]. Here is [[https://www3.svjatoslav.eu/projects/javainspect/legend.png][legend]] to help
+ understand diagrams. Diagrams were generated using [[https://www3.svjatoslav.eu/projects/javainspect/][JavaInspect tool]].
+
+* Feature ideas
+
+- In task directory ignore binary files, use joinfiles command as
+ example how to ignore binary files. Perhaps extract plain text file
+ detection into some utility class.
+
+- Make text editor configurable in application properties file.
+
+- Extend the application's capabilities to include voice capture and
+ processing.
+
+- Implement image capture and processing features.
--- /dev/null
+#!/bin/bash
+
+SYSTEMD_SERVICE_FILE="/etc/systemd/system/alyverkko-cli.service"
+
+
+# Function to install binary and jar
+install_to_opt() {
+ sudo rm -rf /opt/alyverkko-cli/
+ sudo mkdir -p /opt/alyverkko-cli/
+ sudo chmod 755 /opt/alyverkko-cli/
+
+ sudo cp target/alyverkko-cli.jar "/opt/alyverkko-cli/alyverkko-cli.jar"
+ sudo cp "alyverkko-cli" "/opt/alyverkko-cli/alyverkko-cli"
+ sudo chmod +x "/opt/alyverkko-cli/alyverkko-cli"
+ sudo cp logo.png "/opt/alyverkko-cli/logo.png"
+
+ sudo ln -sf "/opt/alyverkko-cli/alyverkko-cli" /usr/bin/alyverkko-cli
+}
+
+# Function to install the desktop launcher
+install_desktop_entry() {
+ sudo cp launchers/alyverkko-cli*.desktop /usr/share/applications/
+ sudo chmod 644 /usr/share/applications/alyverkko-cli*.desktop
+}
+
+# Function to install systemd service
+install_systemd_service() {
+
+ cat <<EOF | sudo tee "$SYSTEMD_SERVICE_FILE" > /dev/null
+[Unit]
+Description=Älyverkko CLI daemon in task processor mode
+After=network.target
+
+[Service]
+User=$USER
+ExecStart=/opt/alyverkko-cli/alyverkko-cli process
+WorkingDirectory=/opt/alyverkko-cli
+Nice=10
+Restart=always
+RestartSec=10
+
+[Install]
+WantedBy=multi-user.target
+EOF
+
+ sudo systemctl daemon-reload
+ sudo systemctl enable alyverkko-cli
+ sudo systemctl start alyverkko-cli
+ sleep 1
+ echo "Systemd service installed, enabled and started. Service status is:"
+ systemctl --no-pager -l status alyverkko-cli
+}
+
+
+# Function to pre-deploy example configuration YAML file
+install_config_file() {
+ local alyverkko_config_dir="${HOME}/.config/alyverkko-cli"
+
+ if [ ! -d "$alyverkko_config_dir" ]; then
+ mkdir -p "$alyverkko_config_dir"
+ cp alyverkko-cli.yaml "$alyverkko_config_dir/"
+ else
+ echo "Configuration directory already exists: $alyverkko_config_dir"
+ fi
+}
+
+# Main installation function
+main() {
+ # Build the application
+ mvn --settings maven.xml clean package
+
+ install_to_opt
+ install_desktop_entry
+ install_config_file
+
+ # Check if systemd service already exists
+ if [ -f "$SYSTEMD_SERVICE_FILE" ]; then
+ echo "Systemd service is already installed."
+ # Display the status without hanging
+ echo "Service status is:"
+ systemctl --no-pager -l status alyverkko-cli
+ else
+ # Install systemd service if requested
+ echo "Do you want to install Älyverkko CLI as a systemd service? (y/N)"
+ read install_service
+
+ if [[ $install_service == [Yy] ]]; then
+ install_systemd_service
+ fi
+ fi
+
+ echo "Installation complete."
+}
+
+# Call the main installation function
+main
--- /dev/null
+[Desktop Entry]
+Type=Application
+Name=Pause Älyverkko CLI
+Comment=Freeze the current llama-cli job
+Icon=media-playback-pause
+Exec=/usr/bin/killall -STOP llama-cli
+Terminal=false
+Categories=Development;
--- /dev/null
+[Desktop Entry]
+Type=Application
+Name=Resume Älyverkko CLI
+Comment=Unfreeze the current llama-cli job
+Icon=media-playback-pause
+Exec=/usr/bin/killall -CONT llama-cli
+Terminal=false
+Categories=Development;
--- /dev/null
+[Desktop Entry]
+Type=Application
+Terminal=true
+Name=Älyverkko CLI
+Comment=Runner for artificial neural network service
+Icon=/opt/alyverkko-cli/logo.png
+Exec=/opt/alyverkko-cli/alyverkko-cli process
+Categories=Development;
--- /dev/null
+<settings xmlns="http://maven.apache.org/SETTINGS/1.0.0"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.0.0
+ http://maven.apache.org/xsd/settings-1.0.0.xsd">
+ <localRepository/>
+ <interactiveMode/>
+ <usePluginRegistry/>
+ <offline/>
+ <pluginGroups/>
+ <servers/>
+ <mirrors/>
+ <proxies/>
+ <profiles/>
+ <activeProfiles/>
+</settings>
\ No newline at end of file
--- /dev/null
+<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://maven.apache.org/POM/4.0.0"
+ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+ <modelVersion>4.0.0</modelVersion>
+ <groupId>eu.svjatoslav</groupId>
+ <artifactId>alyverkko-cli</artifactId>
+ <version>1.0-SNAPSHOT</version>
+ <name>Älyverkko CLI</name>
+ <description>AI engine wrapper</description>
+
+ <properties>
+ <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+ <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
+ </properties>
+
+ <organization>
+ <name>svjatoslav.eu</name>
+ <url>https://svjatoslav.eu</url>
+ </organization>
+
+ <dependencies>
+ <dependency>
+ <groupId>eu.svjatoslav</groupId>
+ <artifactId>svjatoslavcommons</artifactId>
+ <version>1.8</version>
+ </dependency>
+ <dependency>
+ <groupId>eu.svjatoslav</groupId>
+ <artifactId>cli-helper</artifactId>
+ <version>1.3</version>
+ </dependency>
+ <dependency>
+ <groupId>org.testng</groupId>
+ <artifactId>testng</artifactId>
+ <version>7.7.0</version>
+ <scope>test</scope>
+ </dependency>
+ <dependency>
+ <groupId>org.junit.jupiter</groupId>
+ <artifactId>junit-jupiter</artifactId>
+ <version>RELEASE</version>
+ <scope>test</scope>
+ </dependency>
+ <dependency>
+ <groupId>com.fasterxml.jackson.core</groupId>
+ <artifactId>jackson-databind</artifactId>
+ <version>2.13.4.1</version>
+ </dependency>
+ <dependency>
+ <groupId>com.fasterxml.jackson.dataformat</groupId>
+ <artifactId>jackson-dataformat-yaml</artifactId>
+ <version>2.13.0</version>
+ </dependency>
+ <dependency>
+ <groupId>org.apache.commons</groupId>
+ <artifactId>commons-lang3</artifactId>
+ <version>3.12.0</version>
+ </dependency>
+ <dependency>
+ <groupId>org.apache.commons</groupId>
+ <artifactId>commons-io</artifactId>
+ <version>1.3.2</version>
+ </dependency>
+ <dependency>
+ <groupId>org.projectlombok</groupId>
+ <artifactId>lombok</artifactId>
+ <version>1.18.32</version>
+ <scope>provided</scope>
+ </dependency>
+ </dependencies>
+
+ <build>
+ <plugins>
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-compiler-plugin</artifactId>
+ <version>3.8.1</version>
+ <configuration>
+ <source>11</source>
+ <target>11</target>
+ <optimize>true</optimize>
+ <encoding>UTF-8</encoding>
+ </configuration>
+ </plugin>
+
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-source-plugin</artifactId>
+ <version>2.2.1</version>
+ <executions>
+ <execution>
+ <id>attach-sources</id>
+ <goals>
+ <goal>jar</goal>
+ </goals>
+ </execution>
+ </executions>
+ </plugin>
+
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-javadoc-plugin</artifactId>
+ <version>2.10.4</version>
+ <executions>
+ <execution>
+ <id>attach-javadocs</id>
+ <goals>
+ <goal>jar</goal>
+ </goals>
+ </execution>
+ </executions>
+ <configuration>
+ <!-- workaround for https://bugs.openjdk.java.net/browse/JDK-8212233 -->
+ <javaApiLinks>
+ <property>
+ <name>foo</name>
+ <value>bar</value>
+ </property>
+ </javaApiLinks>
+ <!-- Workaround for https://stackoverflow.com/questions/49472783/maven-is-unable-to-find-javadoc-command -->
+ <javadocExecutable>${java.home}/bin/javadoc</javadocExecutable>
+ </configuration>
+ </plugin>
+
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-resources-plugin</artifactId>
+ <version>2.4.3</version>
+ <configuration>
+ <encoding>UTF-8</encoding>
+ </configuration>
+ </plugin>
+
+ <plugin>
+ <artifactId>maven-assembly-plugin</artifactId>
+
+ <configuration>
+ <archive>
+ <manifest>
+ <mainClass>eu.svjatoslav.alyverkko_cli.Main</mainClass>
+ </manifest>
+ </archive>
+ <descriptorRefs>
+ <descriptorRef>jar-with-dependencies</descriptorRef>
+ </descriptorRefs>
+ <finalName>alyverkko-cli</finalName>
+ <appendAssemblyId>false</appendAssemblyId>
+ </configuration>
+
+ <executions>
+ <execution>
+ <id>package-jar-with-dependencies</id>
+ <phase>package</phase>
+ <goals>
+ <goal>single</goal>
+ </goals>
+ <configuration>
+ <descriptorRefs>
+ <descriptorRef>jar-with-dependencies</descriptorRef>
+ </descriptorRefs>
+ <archive>
+ <manifest>
+ <mainClass>eu.svjatoslav.alyverkko_cli.Main</mainClass>
+ </manifest>
+ </archive>
+ </configuration>
+ </execution>
+ </executions>
+ </plugin>
+ </plugins>
+
+ <extensions>
+ <extension>
+ <groupId>org.apache.maven.wagon</groupId>
+ <artifactId>wagon-ssh-external</artifactId>
+ <version>2.6</version>
+ </extension>
+ </extensions>
+ </build>
+
+
+ <distributionManagement>
+ <snapshotRepository>
+ <id>svjatoslav.eu</id>
+ <name>svjatoslav.eu</name>
+ <url>scpexe://svjatoslav.eu:10006/srv/maven</url>
+ </snapshotRepository>
+ <repository>
+ <id>svjatoslav.eu</id>
+ <name>svjatoslav.eu</name>
+ <url>scpexe://svjatoslav.eu:10006/srv/maven</url>
+ </repository>
+ </distributionManagement>
+
+ <repositories>
+ <repository>
+ <id>svjatoslav.eu</id>
+ <name>Svjatoslav repository</name>
+ <url>https://www3.svjatoslav.eu/maven/</url>
+ </repository>
+ </repositories>
+
+ <scm>
+ <connection>scm:git:ssh://n0@svjatoslav.eu/home/git/repositories/alyverkko-cli.git</connection>
+ <developerConnection>scm:git:ssh://n0@svjatoslav.eu/home/git/repositories/alyverkko-cli.git
+ </developerConnection>
+ </scm>
+
+
+</project>
--- /dev/null
+package eu.svjatoslav.alyverkko_cli;
+
+import java.io.IOException;
+
+/**
+ * <p>Base interface for all subcommands in the Älyverkko CLI. Each command must define its name and execution logic.
+ * <p>Commands typically:
+ * <ul>
+ * <li>Parse their own specific arguments</li>
+ * <li>Access the global configuration</li>
+ * <li>Handle I/O operations</li>
+ * </ul>
+ *
+ * <p>Commands should be stateless and self-contained, using the configuration object for persistent data when needed.
+ */
+public interface Command {
+
+ /**
+ * @return the subcommand's name.
+ */
+ String getCommandName();
+
+ /**
+ * Called to carry out the specific subcommand. Typically, reads
+ * command-line arguments and performs the desired action.
+ *
+ * @param args arguments passed after the subcommand name.
+ * @throws IOException if I/O operations fail.
+ * @throws InterruptedException if the operation is interrupted.
+ */
+ void executeCommand(String[] args) throws IOException, InterruptedException;
+}
--- /dev/null
+package eu.svjatoslav.alyverkko_cli;
+
+import eu.svjatoslav.alyverkko_cli.commands.*;
+import eu.svjatoslav.alyverkko_cli.commands.task_processor.TaskProcessorCommand;
+import eu.svjatoslav.alyverkko_cli.configuration.Configuration;
+
+import java.io.IOException;
+import java.util.Optional;
+
+import static java.util.Arrays.copyOfRange;
+
+/**
+ * The main entry point for the Älyverkko CLI application.
+ * It processes subcommands such as "wizard", "selftest", "joinfiles",
+ * "mail", and "listmodels".
+ */
+public class Main {
+
+ /**
+ * The list of all supported subcommands.
+ */
+ private final java.util.List<Command> commands = java.util.Arrays.asList(
+ new ListModelsCommand(),
+ new TaskProcessorCommand(),
+ new JoinFilesCommand(),
+ new WizardCommand()
+ );
+
+ /**
+ * The active, loaded configuration for the entire application.
+ * May be null if the configuration is not loaded properly.
+ */
+ public static Configuration configuration;
+
+ /**
+ * Application entry point. Dispatches to a subcommand if one is
+ * specified; otherwise shows usage help.
+ *
+ * @param args command-line arguments; the first is the subcommand name.
+ */
+ public static void main(final String[] args) throws IOException, InterruptedException {
+ new Main().handleCommand(args);
+ }
+
+ /**
+ * Attempts to find and execute the subcommand specified in the given arguments,
+ * or prints a help message if no command is found.
+ *
+ * @param args the command-line arguments.
+ * @throws IOException if an I/O error occurs during command execution.
+ * @throws InterruptedException if the command is interrupted.
+ */
+ public void handleCommand(String[] args) throws IOException, InterruptedException {
+ if (args.length == 0) {
+ showHelp();
+ return;
+ }
+
+ String commandName = args[0].toLowerCase();
+ Optional<Command> commandOptional = commands.stream()
+ .filter(cmd -> cmd.getCommandName().equals(commandName))
+ .findFirst();
+
+ if (!commandOptional.isPresent()) {
+ System.out.println("Unknown command: " + commandName);
+ showHelp();
+ return;
+ }
+
+ Command command = commandOptional.get();
+ String[] remainingArgs = copyOfRange(args, 1, args.length);
+ command.executeCommand(remainingArgs);
+ }
+
+ /**
+ * Displays a basic help message, listing available commands.
+ */
+ private void showHelp() {
+ System.out.println("Älyverkko CLI\n");
+ System.out.println("Available commands:");
+ commands.forEach(cmd -> System.out.println(" " + cmd.getCommandName()));
+ }
+}
--- /dev/null
+package eu.svjatoslav.alyverkko_cli;
+
+/**
+ * <p>General utility functions for the Älyverkko CLI application. Currently provides ANSI color output capabilities for
+ * console messages.
+ * <p>Color formatting follows standard ANSI escape sequences, with specific methods for common message types like errors.
+ * <p>For future extensions, this class could include additional helper functions for file operations or string processing.
+ */
+public class Utils {
+
+ /**
+ * Prints a message in red text to the console.
+ *
+ * @param message the text to print in red.
+ */
+ public static void printRedMessageToConsole(String message) {
+ // set output color to red
+ System.out.print("\033[0;31m");
+ System.out.print(message + "\n");
+ // reset output color
+ System.out.print("\033[0m");
+ }
+}
--- /dev/null
+package eu.svjatoslav.alyverkko_cli.commands;
+
+import eu.svjatoslav.alyverkko_cli.Command;
+import eu.svjatoslav.commons.cli_helper.parameter_parser.Parser;
+import eu.svjatoslav.commons.cli_helper.parameter_parser.parameter.DirectoryOption;
+import eu.svjatoslav.commons.cli_helper.parameter_parser.parameter.NullOption;
+import eu.svjatoslav.commons.cli_helper.parameter_parser.parameter.StringOption;
+import eu.svjatoslav.commons.string.GlobMatcher;
+
+import java.io.BufferedWriter;
+import java.io.File;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.*;
+
+import static eu.svjatoslav.alyverkko_cli.Main.configuration;
+import static eu.svjatoslav.alyverkko_cli.configuration.ConfigurationHelper.getConfigurationFile;
+import static eu.svjatoslav.alyverkko_cli.configuration.ConfigurationHelper.loadConfiguration;
+
+/**
+ * The JoinFilesCommand aggregates multiple files (optionally matching
+ * a specific pattern) into a single file for AI processing, typically
+ * in the mail directory.
+ *
+ * Usage Example:
+ * <pre>
+ * alyverkko-cli joinfiles -s /path/to/source -p "*.java" -t "my_topic" --edit
+ * </pre>
+ */
+
+public class JoinFilesCommand implements Command {
+
+ /**
+ * A command-line parser to handle joinfiles arguments.
+ */
+ final Parser parser = new Parser();
+
+ /**
+ * Directory from which files will be joined.
+ */
+ public DirectoryOption sourceDirectoryOption = parser.add(new DirectoryOption("Directory to join files from"))
+ .addAliases("--src-dir", "-s")
+ .mustExist();
+
+ /**
+ * Pattern for matching files, such as "*.java".
+ */
+ public StringOption patternOption = parser.add(new StringOption("Pattern to match files"))
+ .addAliases("--pattern", "-p");
+
+ /**
+ * Topic name, used as the basis for the output file name.
+ */
+ public StringOption topic = parser.add(new StringOption("Topic of the joined files"))
+ .addAliases("--topic", "-t")
+ .setMandatory();
+
+ /**
+ * If present, open the joined file using a text editor afterward.
+ */
+ public NullOption editOption = parser.add(new NullOption("Edit the joined file using text editor"))
+ .addAliases("--edit", "-e");
+
+ /**
+ * The base directory for recursion when joining files.
+ */
+ public Path sourceBaseDirectory;
+
+ /**
+ * The pattern used to filter files for joining, e.g. "*.java".
+ */
+ public String fileNamePattern = null;
+
+ /**
+ * The resulting output file that aggregates all matched files.
+ */
+ File outputFile;
+
+ /**
+ * @return the name of this command, i.e., "joinfiles".
+ */
+ @Override
+ public String getCommandName() {
+ return "joinfiles";
+ }
+
+ /**
+ * Executes the command that joins files from a specified directory
+ * (matching an optional pattern) into one output file in the mail
+ * directory. Optionally, it can open the output file in an editor.
+ *
+ * @param cliArguments the command-line arguments after "joinfiles".
+ * @throws IOException if any IO operations fail.
+ */
+ @Override
+ public void executeCommand(String[] cliArguments) throws IOException {
+ configuration = loadConfiguration(getConfigurationFile(null));
+ if (configuration == null){
+ System.out.println("Failed to load configuration file");
+ return;
+ }
+
+ if (!parser.parse(cliArguments)) {
+ System.out.println("Failed to parse command-line arguments");
+ parser.showHelp();
+ return;
+ }
+
+ // Build the path to the target file that is relative to the mail directory
+ outputFile = configuration.getTaskDirectory().toPath().resolve(topic.getValue() + ".org").toFile();
+
+ if (patternOption.isPresent()) {
+ fileNamePattern = patternOption.getValue();
+ joinFiles();
+ }
+
+ if (editOption.isPresent()) {
+ openFileWithEditor();
+ }
+ }
+
+ /**
+ * Opens the joined file with a text editor. Currently uses a
+ * command "emc" as an example—adapt as needed.
+ *
+ * @throws IOException if the launch of the editor fails.
+ */
+ private void openFileWithEditor() throws IOException {
+ String[] cmd = {"emc", outputFile.getAbsolutePath()};
+ Runtime.getRuntime().exec(cmd);
+ }
+
+ /**
+ * Joins the matching files from the configured source directory
+ * into a single file named {@code <topic>.org} in the mail directory.
+ *
+ * @throws IOException if reading or writing files fails.
+ */
+ private void joinFiles() throws IOException {
+ boolean appendToFile = outputFile.exists();
+
+ if (sourceDirectoryOption.isPresent()) {
+ sourceBaseDirectory = sourceDirectoryOption.getValue().toPath();
+ } else {
+ sourceBaseDirectory = Paths.get(".");
+ }
+
+ try (BufferedWriter writer = Files.newBufferedWriter(
+ outputFile.toPath(), StandardCharsets.UTF_8,
+ appendToFile ? StandardOpenOption.APPEND : StandardOpenOption.CREATE)) {
+
+ // Recursively join files that match the pattern
+ joinFilesRecursively(sourceBaseDirectory, writer);
+ }
+
+ System.out.println("Files have been joined into: " + outputFile.getAbsolutePath());
+ }
+
+ /**
+ * Recursively traverses the specified directory and writes the contents
+ * of files that match the specified {@link #fileNamePattern}.
+ *
+ * @param directoryToIndex the directory to be searched recursively.
+ * @param writer the writer to which file contents are appended.
+ * @throws IOException if file reading fails.
+ */
+ private void joinFilesRecursively(Path directoryToIndex, BufferedWriter writer) throws IOException {
+ try (DirectoryStream<Path> stream = Files.newDirectoryStream(directoryToIndex)) {
+ for (Path entry : stream) {
+ if (Files.isDirectory(entry)) {
+ joinFilesRecursively(entry, writer);
+ } else if (Files.isRegularFile(entry)) {
+ String fileName = entry.getFileName().toString();
+
+ boolean match = GlobMatcher.match(fileName, fileNamePattern);
+ if (match) {
+ System.out.println("Joining file: " + fileName);
+ writeFile(writer, entry);
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * Writes the contents of a single file to the specified writer,
+ * including a small header containing the file path.
+ *
+ * @param writer the writer to which file contents are appended.
+ * @param entry the file to read and write.
+ * @throws IOException if file reading or writing fails.
+ */
+ private void writeFile(BufferedWriter writer, Path entry) throws IOException {
+ writeFileHeader(writer, entry);
+
+ String fileContent = new String(Files.readAllBytes(entry), StandardCharsets.UTF_8);
+
+ // remove empty lines from the beginning and end of the file
+ fileContent = fileContent.replaceAll("(?m)^\\s*$", "");
+
+ writer.write(fileContent + "\n");
+ }
+
+ /**
+ * Writes a small header line to indicate which file is being appended.
+ *
+ * @param writer the writer to which the header is appended.
+ * @param entry the path of the current file.
+ * @throws IOException if writing fails.
+ */
+ private void writeFileHeader(BufferedWriter writer, Path entry) throws IOException {
+ String relativePath = sourceBaseDirectory.relativize(entry).toString();
+ writer.write("* file: " + relativePath + "\n\n");
+ }
+}
--- /dev/null
+package eu.svjatoslav.alyverkko_cli.commands;
+
+import eu.svjatoslav.alyverkko_cli.Command;
+import eu.svjatoslav.alyverkko_cli.model.ModelLibrary;
+
+import java.io.IOException;
+
+import static eu.svjatoslav.alyverkko_cli.Main.configuration;
+import static eu.svjatoslav.alyverkko_cli.configuration.ConfigurationHelper.getConfigurationFile;
+import static eu.svjatoslav.alyverkko_cli.configuration.ConfigurationHelper.loadConfiguration;
+
+/**
+ * <p>Displays all available AI models in the configured models directory. This command provides a quick overview of
+ * currently available models and their metadata.
+ * <p>The implementation:
+ * <ul>
+ * <li>Loads the configuration</li>
+ * <li>Instantiates ModelLibrary</li>
+ * <li>Prints model details using ModelLibrary's printModels()</li>
+ * </ul>
+ *
+ * <p>This command is primarily intended for administrative use to verify model availability before running tasks.
+ */
+public class ListModelsCommand implements Command {
+
+ /**
+ * @return the name of this command, i.e., "listmodels".
+ */
+ @Override
+ public String getCommandName() {
+ return "listmodels";
+ }
+
+ /**
+ * Executes the command to load the user's configuration and list
+ * all known AI models, printing them to stdout.
+ *
+ * @param cliArguments the command-line arguments after "listmodels".
+ * @throws IOException if loading configuration fails.
+ */
+ @Override
+ public void executeCommand(String[] cliArguments) throws IOException {
+ configuration = loadConfiguration(getConfigurationFile(null));
+ if (configuration == null){
+ System.out.println("Failed to load configuration file");
+ return;
+ }
+
+ System.out.println("Listing models in directory: " + configuration.getModelsDirectory());
+ ModelLibrary modelLibrary = new ModelLibrary(configuration.getModelsDirectory(), configuration.getModels());
+ modelLibrary.printModels();
+ }
+}
--- /dev/null
+package eu.svjatoslav.alyverkko_cli.commands;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
+import eu.svjatoslav.alyverkko_cli.Command;
+import eu.svjatoslav.alyverkko_cli.configuration.Configuration;
+import eu.svjatoslav.alyverkko_cli.configuration.ConfigurationHelper;
+import eu.svjatoslav.alyverkko_cli.configuration.ConfigurationModel;
+import eu.svjatoslav.commons.cli_helper.parameter_parser.Parser;
+import eu.svjatoslav.commons.cli_helper.parameter_parser.parameter.FileOption;
+
+import java.io.*;
+import java.nio.file.*;
+import java.util.ArrayList;
+import java.util.List;
+
+import static eu.svjatoslav.alyverkko_cli.Utils.printRedMessageToConsole;
+import static eu.svjatoslav.alyverkko_cli.configuration.ConfigurationHelper.getConfigurationFile;
+import static eu.svjatoslav.commons.cli_helper.CLIHelper.*;
+
+/**
+ * <p>Interactive configuration wizard that helps users validate and fix their configuration files.
+ * It performs system checks and offers to fix any missing or invalid paths, discovers new models,
+ * and updates the configuration accordingly.
+ * <p>Key workflow steps:
+ * <ol>
+ * <li>Load or create configuration</li>
+ * <li>Validate core directory paths</li>
+ * <li>Discover and annotate new models</li>
+ * <li>Save updated configuration</li>
+ * </ol>
+ * <p>When handling split models (.gguf files with part numbering), the wizard automatically
+ * detects base models and only adds part-1 files to the configuration.
+ */
+public class WizardCommand implements Command {
+
+ // Command-line parser to handle wizard arguments
+ private final Parser cliParser = new Parser();
+
+ /**
+ * Optional CLI argument for specifying a configuration file path.
+ */
+ public FileOption configFileOption = cliParser.add(new FileOption("Configuration file path"))
+ .addAliases("--config", "-c");
+
+ /**
+ * The configuration object (loaded or newly created)
+ */
+ private Configuration configuration;
+
+ private File configurationFile;
+
+ private boolean configurationUpdated = false;
+ private boolean modelsUpdated = false;
+
+ @Override
+ public String getCommandName() {
+ return "wizard";
+ }
+
+ @Override
+ public void executeCommand(String[] cliArguments) throws IOException {
+ // Parse command-line arguments
+ if (!cliParser.parse(cliArguments)) {
+ System.out.println("Failed to parse command-line arguments");
+ cliParser.showHelp();
+ return;
+ }
+
+ configurationFile = getConfigurationFile(configFileOption);
+ loadOrCreateConfiguration();
+
+ checkAndFixGeneralParameters();
+
+ fixModelEntries();
+
+ trySaveConfiguration();
+
+ if (modelsUpdated) {
+ System.out.println("Configuration has been updated. Please open the configuration file in a text editor to review and adjust model settings as needed.");
+ }
+ }
+
+ private void loadOrCreateConfiguration() throws IOException {
+ validateConfigurationFile();
+
+ if (configurationFile.exists()) {
+ System.out.println("Found existing configuration at: \"" + configurationFile.getAbsolutePath() + "\"");
+ configuration = ConfigurationHelper.loadConfiguration(configurationFile);
+ } else {
+ // If no config found, create a fresh one
+ System.out.println("Existing configuration not found at \""
+ + configurationFile.getAbsolutePath() + "\". Initializing new blank configuration.");
+ configuration = new Configuration();
+ configurationUpdated = true;
+ }
+ }
+
+ /**
+ * Validates the configuration file path and checks if it is a valid file.
+ * If not, prints an error message and exits the program.
+ */
+ private void validateConfigurationFile() {
+ if (!configurationFile.exists()) return; // No need to check further if it doesn't exist
+
+ // Check if the file is a directory
+ if (configurationFile.isDirectory()) {
+ System.err.println("ERROR: Configuration file path incorrectly points to a directory: \"" + configurationFile.getAbsolutePath()
+ + "\". Please specify a file instead.");
+ System.exit(1);
+ }
+
+ // Check if the file is readable
+ if (!configurationFile.canRead()) {
+ System.err.println("ERROR: Cannot read configuration file: \"" + configurationFile.getAbsolutePath()
+ + "\". Please check permissions.");
+ System.exit(1);
+ }
+
+ // Check if the file is writable
+ if (!configurationFile.canWrite()) {
+ System.err.println("ERROR: Cannot write to configuration file: \"" + configurationFile.getAbsolutePath() +
+ "\". Please check file permissions.");
+ System.exit(1);
+ }
+ }
+
+ /**
+ * Step-by-step checking (and possibly fixing) of each main config parameter.
+ */
+ private void checkAndFixGeneralParameters() {
+ configuration.setTaskDirectory(
+ checkDirectory(
+ configuration.getTaskDirectory(),
+ "Mail directory",
+ true,
+ "The mail directory is where the AI will look for tasks to solve. " +
+ "It should be a directory that you can write to. Please specify new mail directory path.",
+ true)
+ );
+
+ configuration.setModelsDirectory(
+ checkDirectory(
+ configuration.getModelsDirectory(),
+ "Models directory",
+ null,
+ "The models directory is where the AI model files (*.gguf) are stored. " +
+ "Please specify new models directory path.",
+ true)
+ );
+
+ configuration.setSkillsDirectory(
+ checkDirectory(
+ configuration.getSkillsDirectory(),
+ "Prompts directory",
+ null,
+ "The prompts directory is where the AI prompt files (*.yaml) are stored. " +
+ "Please specify new prompts directory path.",
+ true)
+ );
+
+ configuration.setLlamaCliPath(
+ checkFile(
+ configuration.getLlamaCliPath(),
+ "llama.cpp project llama-cli executable file path",
+ "The llama-cli is commandline engine that runs GGUF language models. " +
+ "Usually it is located under build/bin/ directory within llama.cpp project.",
+ true,
+ true)
+ );
+
+ // Default_temperature
+ Float temperature = configuration.getDefaultTemperature();
+ if (temperature == null || temperature < 0f || temperature > 3f) {
+ configuration.setDefaultTemperature(askFloat(
+ "Enter default temperature (0-3). Lower => more deterministic, higher => more creative.",
+ temperature,
+ 0f, 3f, false
+ ));
+ }
+ // Thread_count
+ Integer threadCount = configuration.getThreadCount();
+ if (threadCount == null || threadCount < 1) {
+ int defaultThreadCount = Runtime.getRuntime().availableProcessors() / 2;
+ if (defaultThreadCount < 1) defaultThreadCount = 1;
+ configuration.setThreadCount(askInteger(
+ "Enter number of CPU threads for AI generation. Typically RAM bandwidth gets saturated " +
+ "first and becomes bottleneck before all CPU cores can get fully utilized. So for 12 core CPU" +
+ " it might be enough to set 6 threads. Increasing this number higher yields diminishing returns.",
+ defaultThreadCount,
+ 1, null, false
+ ));
+ }
+
+ // Batch thread count
+ Integer batchThreadCount = configuration.getBatchThreadCount();
+ if (batchThreadCount == null || batchThreadCount < 1) {
+ int defaultThreadCount = Runtime.getRuntime().availableProcessors();
+ configuration.setBatchThreadCount(
+ askInteger(
+ "\nEnter number of CPU threads for input prompt processing (all cores is often fine).",
+ defaultThreadCount,
+ 1, null, false
+ ));
+ }
+ }
+
+ /**
+ * Validates a directory path and prompts user to fix if needed.
+ * @param directory current directory value
+ * @param directoryName name to display to user
+ * @param writable if directory must be writable (null = no check)
+ * @param explanation message to show user when prompting
+ * @param offerToCreate if true, offers to create directory if missing
+ * @return validated directory path
+ */
+ private File checkDirectory(
+ File directory,
+ String directoryName,
+ Boolean writable,
+ String explanation,
+ boolean offerToCreate) {
+
+ while (true) {
+ // Check if the directory is null
+ boolean allOk = true;
+ if (directory == null) {
+ System.out.println(directoryName + " is not defined.");
+ allOk = false;
+ directory = getNewDirectoryPath(explanation, writable);
+ }
+
+ if (!directory.exists()) {
+ System.out.println(directoryName + " does not exist: " + directory.getAbsolutePath());
+ allOk = false;
+ // Offer to create it
+ if (offerToCreate) {
+ if (askBoolean("Create " + directoryName + " ?", true)) {
+ boolean created = directory.mkdirs();
+ if (!created) {
+ printRedMessageToConsole("Failed to create \"" + directory + "\". Check permissions");
+ } else {
+ System.out.println(directoryName + " created at: " + directory.getAbsolutePath());
+ }
+ }
+ }
+ }
+
+ if (!directory.isDirectory()) {
+ System.out.println(directoryName + " is not a directory: " + directory.getAbsolutePath());
+ allOk = false;
+ directory = getNewDirectoryPath(explanation, writable);
+ }
+
+ if (writable != null && writable && !directory.canWrite()) {
+ System.out.println(directoryName + " is not writable: " + directory.getAbsolutePath());
+ allOk = false;
+ directory = getNewDirectoryPath(explanation, writable);
+ }
+
+ if (allOk) {
+ System.out.println(directoryName + " is: " + directory.getAbsolutePath());
+ return directory;
+ }
+
+ configurationUpdated = true;
+ }
+ }
+
+ /**
+ * Validates a file path and prompts user to fix if needed.
+ * @param file current file value
+ * @param fileName name to display to user
+ * @param mustExist if file must exist (null = no check)
+ * @param explanation message to show user when prompting
+ * @param executable if file must be executable (null = no check)
+ * @return validated file path
+ */
+ private File checkFile(
+ File file,
+ String fileName,
+ String explanation,
+ Boolean mustExist,
+ Boolean executable) {
+
+ while (true) {
+ boolean allOk = true;
+ if (file == null) {
+ System.out.println(fileName + " is not defined.");
+ allOk = false;
+ file = askFile(explanation, null, mustExist, null, null, executable, false);
+ }
+
+ if (mustExist != null && mustExist && !file.exists()) {
+ System.out.println(fileName + " does not exist: " + file.getAbsolutePath());
+ allOk = false;
+ file = askFile(explanation, null, mustExist, null, null, executable, false);
+ }
+
+ if (!file.isFile()) {
+ System.out.println(fileName + " is not a file: " + file.getAbsolutePath());
+ allOk = false;
+ file = askFile(explanation, null, null, null, null, executable, false);
+ }
+
+ if (executable != null && executable && !file.canExecute()) {
+ System.out.println(fileName + " is not executable: " + file.getAbsolutePath());
+ allOk = false;
+ file = askFile(explanation, null, true, null, null, executable, false);
+ }
+
+ if (allOk) {
+ System.out.println(fileName + " is: " + file.getAbsolutePath());
+ return file;
+ }
+
+ configurationUpdated = true;
+ }
+ }
+
+ private static File getNewDirectoryPath(String directoryName, Boolean writable) {
+ return askDirectory(directoryName, null, null, null, writable, null, false);
+ }
+
+ /**
+ * Saves the config to the default path, verifying if user wants to
+ * overwrite if it already exists, etc.
+ */
+ private void trySaveConfiguration() {
+ if (!configurationUpdated) {
+ System.out.println("No changes made to the configuration. Not saving.");
+ return;
+ }
+
+ // ask user if user wants to save configuration
+ if (!askBoolean("Save configuration to: " + configurationFile, true, false))
+ return;
+
+ boolean fileExisted = configurationFile.exists();
+
+ try {
+ Files.createDirectories(configurationFile.toPath().getParent());
+ try (BufferedWriter writer = Files.newBufferedWriter(
+ configurationFile.toPath(),
+ StandardOpenOption.CREATE,
+ StandardOpenOption.TRUNCATE_EXISTING
+ )) {
+ new ObjectMapper(new YAMLFactory()).writeValue(writer, configuration);
+ }
+ if (fileExisted) {
+ System.out.println("Existing configuration updated at: " + configurationFile.toPath());
+ } else {
+ System.out.println("New configuration created at: " + configurationFile.toPath());
+ }
+ } catch (IOException e) {
+ printRedMessageToConsole("Error saving configuration: " + e.getMessage());
+ }
+ }
+
+ /**
+ * Generates an alias from a .gguf filename by removing non-alphanumeric chars.
+ */
+ private String suggestAlias(String filePath) {
+ String fileName = new File(filePath).getName();
+ // Remove .gguf extension
+ fileName = fileName.replaceFirst("\\.gguf$", "");
+ // Check if it's a split model
+ if (fileName.matches(".*-\\d{5}-of-\\d{5}")) {
+ // Extract base name by removing the part numbers
+ fileName = fileName.replaceFirst("-\\d{5}-of-\\d{5}", "");
+ }
+ // Replace non-alphanumeric characters with hyphens
+ String alias = fileName.replaceAll("[^a-zA-Z0-9]", "-").toLowerCase();
+ // Normalize hyphens and trim leading/trailing hyphens
+ return alias.replaceAll("-+", "-").replaceAll("^-|-$", "");
+ }
+
+ private void fixModelEntries() {
+ File modelsDir = configuration.getModelsDirectory();
+ if (modelsDir == null || !modelsDir.exists() || !modelsDir.isDirectory()) {
+ System.out.println("Models directory is not properly configured. Skipping model checks.");
+ return;
+ }
+
+ List<ConfigurationModel> existingModels = configuration.getModels();
+ if (existingModels == null) {
+ existingModels = new ArrayList<>();
+ configuration.setModels(existingModels);
+ configurationUpdated = true;
+ }
+
+ annotateMissingModels();
+
+ discoverNewModels();
+ }
+
+ private void discoverNewModels() {
+ // List all .gguf files in models directory
+ File[] files = configuration.getModelsDirectory().listFiles((dir, name) -> name.endsWith(".gguf"));
+ if (files == null) return;
+
+ for (File file : files) {
+ String relativePath = configuration.getModelsDirectory().toPath().relativize(file.toPath()).toString();
+ if (isExistingModel(relativePath)) continue;
+
+ processPotentialNewModelFile(file, relativePath);
+ }
+ }
+
+ private boolean isExistingModel(String relativePath) {
+ return configuration.getModels().stream()
+ .anyMatch(m -> m.getFilesystemPath().equals(relativePath));
+ }
+
+ private void processPotentialNewModelFile(File file, String relativePath) {
+ // Check if it's a split model
+ if (isSplitModel(file.getName())) {
+ handleSplitModel(file, relativePath);
+ } else {
+ addNewModel(relativePath);
+ }
+ }
+
+ private boolean isSplitModel(String fileName) {
+ return fileName.matches(".*-\\d{5}-of-\\d{5}\\.gguf");
+ }
+
+ private void handleSplitModel(File file, String relativePath) {
+ String baseName = relativePath.replaceFirst("-\\d{5}-of-\\d{5}\\.gguf", "");
+ if (configuration.getModels().stream().anyMatch(m -> m.getAlias().startsWith(baseName))) {
+ return;
+ }
+
+ // Extract part number
+ String partNumberStr = relativePath.replaceAll(".*-(\\d{5}-of-\\d{5}\\.gguf)", "$1");
+ int partNumber = Integer.parseInt(partNumberStr.split("-of-")[0]);
+ if (partNumber == 1) {
+ addNewModel(relativePath);
+ }
+ }
+
+ private void addNewModel(String relativePath) {
+ ConfigurationModel newModel = getNewModel(relativePath);
+ configuration.getModels().add(newModel);
+ System.out.println("Added new model: " + newModel.getAlias() + " (" + newModel.getFilesystemPath() + ")");
+ configurationUpdated = true;
+ modelsUpdated = true;
+ }
+
+ private ConfigurationModel getNewModel(String relativePath) {
+ String suggestedAlias = suggestAlias(relativePath);
+ ConfigurationModel newModel = new ConfigurationModel();
+ newModel.setAlias(suggestedAlias + "-new");
+ newModel.setFilesystemPath(relativePath);
+ newModel.setContextSizeTokens(32768); // Default context size
+ newModel.setEndOfTextMarker(null); // Default end marker
+ return newModel;
+ }
+
+ private void annotateMissingModels() {
+ // Process existing models to add/remove -missing suffix
+ for (ConfigurationModel model : configuration.getModels()) {
+ File modelFile = new File(configuration.getModelsDirectory(), model.getFilesystemPath());
+ if (!modelFile.exists()) {
+ if (!model.getAlias().endsWith("-missing")) {
+ model.setAlias(model.getAlias() + "-missing");
+ System.out.println("Marked model as missing: " + model.getAlias());
+ configurationUpdated = true;
+ modelsUpdated = true;
+ }
+ } else {
+ if (model.getAlias().endsWith("-missing")) {
+ model.setAlias(model.getAlias().replaceFirst("-missing$", ""));
+ System.out.println("Removed -missing suffix from model: " + model.getAlias());
+ configurationUpdated = true;
+ modelsUpdated = true;
+ }
+ }
+ }
+ }
+
+}
--- /dev/null
+/**
+ * <p>This package implements all subcommands available in the Älyverkko CLI application. Each command class provides a
+ * specific functionality through the Command interface.
+ * <p>Available commands include:
+ * <ul>
+ * <li>Wizard-style configuration builder</li>
+ * <li>Model listing and management</li>
+ * <li>File joining for multi-file processing</li>
+ * <li>Mail-based AI task processing</li>
+ * </ul>
+ */
+
+package eu.svjatoslav.alyverkko_cli.commands;
\ No newline at end of file
--- /dev/null
+package eu.svjatoslav.alyverkko_cli.commands.task_processor;
+
+import eu.svjatoslav.alyverkko_cli.configuration.SkillConfig;
+import eu.svjatoslav.alyverkko_cli.model.Model;
+
+import static eu.svjatoslav.alyverkko_cli.Main.configuration;
+
+/**
+ * Represents the data needed to perform a single task based AI query,
+ * containing prompts and the specific AI model to use.
+ */
+public class Task {
+
+ /**
+ * The system prompt text that sets the context or role for the AI.
+ * This is often used to establish rules or background instructions
+ * for how the assistant should behave.
+ */
+ public String systemPrompt;
+
+ /**
+ * The name of the specific skill or capability that the AI should utilize
+ * when processing the query. This can help determine the focus or purpose
+ * of the response generated by the AI.
+ */
+ public String skillName;
+
+ public SkillConfig skill;
+
+ /**
+ * The user's prompt text (the main request or query).
+ */
+ public String userPrompt;
+
+ /**
+ * The AI model to be used for processing this query.
+ */
+ public Model model;
+
+ /**
+ * The start time of the query (milliseconds since epoch).
+ */
+ public long startTimeMillis;
+
+ /**
+ * The end time of the query (milliseconds since epoch).
+ */
+ public long endTimeMillis;
+
+ /**
+ * Task priority. Bigger integer has higher priority.
+ */
+ public int priority;
+
+
+ /**
+ * Returns a string containing a summary of the {@link Task} object.
+ *
+ * @return a string with the system prompt, user prompt, and model info.
+ */
+ @Override
+ public String toString() {
+ return "MailQuery{" +
+ "systemPrompt='" + systemPrompt + '\'' +
+ ", userPrompt='" + userPrompt + '\'' +
+ ", model=" + model +
+ '}';
+ }
+
+ /**
+ * Calculate effective temperature based on override hierarchy.
+ */
+ public float getEffectiveTemperature() {
+ // 1. Skill specific temperature has priority
+ if (skill != null && skill.getTemperature() != null) {
+ return skill.getTemperature();
+ }
+
+ // 2. If not in skill, check if model specifies it
+ if (model.temperature != null) {
+ return model.temperature;
+ }
+
+ // 3. Fall back to global default
+ return configuration.getDefaultTemperature();
+ }
+
+ /**
+ * Calculates effective top-p value using hierarchy:
+ * skill-specific → model-specific → global default
+ *
+ * @return the applicable top-p value for this query
+ */
+ public float getEffectiveTopP() {
+ // Skill-specific has highest priority
+ if (skill != null && skill.getTopP() != null) {
+ return skill.getTopP();
+ }
+
+ // Model-specific next
+ if (model.topP != null) {
+ return model.topP;
+ }
+
+ // Global default as fallback
+ return configuration.getDefaultTopP();
+ }
+
+ /**
+ * Calculates effective repeat penalty using hierarchy:
+ * skill-specific → model-specific → global default
+ *
+ * @return the applicable repeat penalty for this query
+ */
+ public float getEffectiveRepeatPenalty() {
+ // Skill-specific has highest priority
+ if (skill != null && skill.getRepeatPenalty() != null) {
+ return skill.getRepeatPenalty();
+ }
+
+ // Model-specific next
+ if (model.repeatPenalty != null) {
+ return model.repeatPenalty;
+ }
+
+ // Global default as fallback
+ return configuration.getDefaultRepeatPenalty();
+ }
+
+
+ public float getEffectiveTopK() {
+ if (skill != null && skill.getTopK() != null) return skill.getTopK();
+ else if (model.topK != null) return model.topK.floatValue();
+ else return configuration.getDefaultTopK().floatValue();
+ }
+
+ public float getEffectiveMinP() {
+ if (skill != null && skill.getMinP() != null) return skill.getMinP();
+ else if (model.minP != null) return model.minP.floatValue();
+ else return configuration.getDefaultMinP().floatValue();
+ }
+
+
+}
--- /dev/null
+package eu.svjatoslav.alyverkko_cli.commands.task_processor;
+
+import eu.svjatoslav.alyverkko_cli.Utils;
+
+import java.io.*;
+import java.nio.file.Files;
+
+import static eu.svjatoslav.alyverkko_cli.Main.configuration;
+import static java.lang.String.join;
+
+/**
+ *
+ * TODO: what if directory disappeared that contained original input file ? Response cannot be written back anymore.
+ *
+ * <p>Executes AI inference tasks through llama.cpp CLI. This class handles the complete workflow
+ * from prompt construction to response formatting, including temporary file management and process execution.
+ * <p>Key processing steps:
+ * <ol>
+ * <li>Build standardized input prompt</li>
+ * <li>Create a temporary input file</li>
+ * <li>Execute llama.cpp with appropriate parameters</li>
+ * <li>Capture and filter output</li>
+ * <li>Perform cleanup operations</li>
+ * </ol>
+ *
+ * <p>Temperature settings, context size, and thread counts are all derived from the current configuration.
+ * The response is formatted to match org-mode conventions while preserving original conversation structure.
+ */
+public class TaskProcess {
+
+ /**
+ * Marker used by llama.cpp to print metadata. We monitor and display these lines.
+ */
+ private static final String LLAMA_CPP_META_INFO_MARKER = "llm_load_print_meta: ";
+
+ /**
+ * The mail query defining system prompt, user prompt, and which model to use.
+ */
+ private final Task task;
+
+ /**
+ * Temporary file used as input to the llama.cpp CLI.
+ */
+ private File inputFile;
+
+ /**
+ * Creates a new AI task with a given mail query.
+ *
+ * @param task the mail query containing model and prompts.
+ */
+ public TaskProcess(Task task) {
+ this.task = task;
+ }
+
+ /**
+ * Builds the prompt text that is fed to llama.cpp, including the system prompt,
+ * the user prompt, and an "ASSISTANT:" marker signifying where the AI response begins.
+ *
+ * @return a string containing the fully prepared query prompt.
+ */
+ private String buildAiQuery() {
+ return task.systemPrompt.replace("<TASK-FILE>", task.userPrompt);
+ }
+
+ /**
+ * Runs the AI query by constructing the prompt, writing it to a temp file,
+ * invoking llama.cpp, collecting output, and performing any final cleanup.
+ *
+ * @return the AI's response in a format suitable for appending back into
+ * the conversation file.
+ * @throws InterruptedException if the process is interrupted.
+ * @throws IOException if reading/writing the file fails or the process fails to start.
+ */
+ public String runAiQuery() throws InterruptedException, IOException {
+ try {
+ // Record the start time of the query
+ task.startTimeMillis = System.currentTimeMillis();
+
+ // Build input prompt
+ initializeInputFile(buildAiQuery());
+
+ // Prepare a process builder
+ ProcessBuilder processBuilder = new ProcessBuilder();
+ processBuilder.command(getCliCommand().split("\\s+")); // Splitting the command string into tokens
+
+ // Start process
+ Process process = processBuilder.start();
+
+ // Handle process's error stream
+ handleErrorThread(process);
+
+ // Handle the process's output stream
+ StringBuilder result = new StringBuilder();
+ Thread outputThread = handleResultThread(process, result);
+
+ // Wait for the process to finish
+ process.waitFor();
+
+ // Wait for the output thread to finish reading
+ outputThread.join();
+
+ // Record the end time of the query
+ task.endTimeMillis = System.currentTimeMillis();
+
+ // Clean up the AI response: remove partial prompt text, end-of-text marker, etc.
+ return cleanupAiResponse(result.toString());
+ } finally {
+ deleteTemporaryFile();
+ }
+ }
+
+ /**
+ * Creates a temporary file for the AI input and writes the prompt to it.
+ *
+ * @param aiQuery the final prompt string for the AI to process.
+ * @throws IOException if file creation or writing fails.
+ */
+ private void initializeInputFile(String aiQuery) throws IOException {
+ inputFile = createTemporaryFile();
+ Files.write(inputFile.toPath(), aiQuery.getBytes());
+ }
+
+ /**
+ * Creates a temporary file that will be used for the AI prompt input.
+ *
+ * @return a new {@link File} referencing the created temporary file.
+ * @throws IOException if the file could not be created.
+ */
+ private File createTemporaryFile() throws IOException {
+ File file = Files.createTempFile("ai-inference", ".tmp").toFile();
+ file.deleteOnExit();
+ return file;
+ }
+
+ /**
+ * Cleans up the AI response by removing the partial text before the
+ * AI response marker and after the end-of-text marker, if specified.
+ *
+ * @param result the raw output from llama.cpp.
+ * @return the cleaned AI response.
+ */
+ private String cleanupAiResponse(String result) {
+
+ // remove text after the end of text marker if it exists
+ if (task.model.endOfTextMarker != null) {
+ int endOfTextMarkerIndex = result.indexOf(task.model.endOfTextMarker);
+ if (endOfTextMarkerIndex != -1) {
+ result = result.substring(0, endOfTextMarkerIndex);
+ }
+ }
+
+ return result;
+ }
+
+ /**
+ * Returns the full command string used to run the AI inference via llama.cpp.
+ *
+ * @return a string representing the command and all arguments.
+ */
+ private String getCliCommand() {
+ int niceValue = 10; // niceness level for background tasks
+ String executablePath = configuration.getLlamaCliPath().getAbsolutePath();
+
+ return join(" ",
+ "nice", "-n", Integer.toString(niceValue),
+ executablePath,
+ "--model " + task.model.filesystemPath,
+ "--threads " + configuration.getThreadCount(),
+ "--threads-batch " + configuration.getBatchThreadCount(),
+
+ // Restricts token selection to the smallest possible set of tokens whose cumulative probability
+ // exceeds the specified threshold P.
+ "--top-p " + task.getEffectiveTopP(),
+
+ "--repeat-penalty " + task.getEffectiveRepeatPenalty(),
+
+ // Restricts token selection to the K tokens with the highest probabilities.
+ "--top-k " + task.getEffectiveTopK(),
+
+ // Filters the vocabulary to include only tokens whose
+ // probability is at least a certain fraction (Min P) of the
+ // probability of the most likely token.
+ "--min-p " + task.getEffectiveMinP(),
+
+ // Ensure that model sees the <|im_start|>system … / <|im_start|>user … markup it was trained on
+ // "--chat-format qwen3",
+
+ // Last n tokens to consider for penalizing repetition
+ "--repeat-last-n 512",
+
+ // Controls the strength of the penalty for a detected repetition sequence.
+ //"--dry-multiplier 0.1",
+
+ // In a code we want the model to reuse the same variable names, keywords, and syntax consistently.
+ // A presence penalty, even a small 0.1, could cause the model to needlessly rename variables.
+ //"--presence-penalty 1",
+
+ "--mirostat 0", // Disable mirostat
+
+ "--no-display-prompt",
+ "--no-warmup",
+ "--flash-attn on",
+ "--temp " + task.getEffectiveTemperature(),
+ "--ctx-size " + task.model.contextSizeTokens,
+ "--batch-size 512",
+ "--single-turn", // run conversation for a single turn only, then exit when done
+ "-n -1",
+ "--file " + inputFile
+
+ // "--cache-type-k q8_0",
+ // might save RAM, need to test if precision loss is acceptable
+
+ // might save RAM, need to test if precision loss is acceptable
+ // "--cache-type-v q8_0",
+
+ );
+
+
+ }
+
+
+
+
+ /**
+ * Spawns a new Thread to handle the error stream from llama.cpp,
+ * printing lines that contain metadata or errors to the console.
+ *
+ * @param process the process whose error stream is consumed.
+ */
+ private static void handleErrorThread(Process process) {
+ Thread errorThread = new Thread(() -> {
+ try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getErrorStream()))) {
+ String line;
+ while ((line = reader.readLine()) != null) {
+ handleErrorStreamLine(line);
+ }
+ } catch (IOException e) {
+ System.err.println("Error reading error stream: " + e.getMessage());
+ }
+ });
+ errorThread.start();
+ }
+
+ /**
+ * Decides what to do with each line from the error stream:
+ * if it matches the llama.cpp meta-info marker, print it normally;
+ * otherwise print as an error.
+ *
+ * @param line a line from the llama.cpp error stream.
+ */
+ private static void handleErrorStreamLine(String line) {
+ if (line.startsWith(LLAMA_CPP_META_INFO_MARKER)) {
+ // Print the meta-info to the console in normal color
+ System.out.println(line.substring(LLAMA_CPP_META_INFO_MARKER.length()));
+ } else {
+ // Print actual error lines in red
+ Utils.printRedMessageToConsole(line);
+ }
+ }
+
+ /**
+ * Consumes the standard output (inference result) from the
+ * llama.cpp process, storing it into a result buffer for further
+ * cleanup, while simultaneously printing it to the console.
+ *
+ * @param process the AI inference process.
+ * @param result a string builder to accumulate the final result.
+ * @return the thread that is reading the output stream.
+ */
+ private static Thread handleResultThread(Process process, StringBuilder result) {
+ Thread outputThread = new Thread(() -> {
+ try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
+ String aiResultLine;
+ while ((aiResultLine = reader.readLine()) != null) {
+ System.out.print("AI: " + aiResultLine + "\n"); // Show each line in real-time
+ result.append(aiResultLine).append("\n");
+ }
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ });
+ outputThread.start();
+ return outputThread;
+ }
+
+ /**
+ * Deletes the temporary input file once processing is complete.
+ */
+ private void deleteTemporaryFile() {
+ if (inputFile != null && inputFile.exists()) {
+ try {
+ Files.delete(inputFile.toPath());
+ } catch (IOException e) {
+ System.err.println("Failed to delete temporary file: " + e.getMessage());
+ }
+ }
+ }
+}
--- /dev/null
+package eu.svjatoslav.alyverkko_cli.commands.task_processor;
+
+import eu.svjatoslav.alyverkko_cli.*;
+import eu.svjatoslav.alyverkko_cli.configuration.ConfigurationHelper;
+import eu.svjatoslav.alyverkko_cli.configuration.SkillConfig;
+import eu.svjatoslav.alyverkko_cli.model.Model;
+import eu.svjatoslav.alyverkko_cli.model.ModelLibrary;
+import eu.svjatoslav.commons.cli_helper.parameter_parser.Parser;
+import eu.svjatoslav.commons.cli_helper.parameter_parser.parameter.FileOption;
+
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.FileReader;
+import java.io.IOException;
+import java.nio.file.*;
+import java.util.*;
+
+import static eu.svjatoslav.alyverkko_cli.Main.configuration;
+import static eu.svjatoslav.commons.file.IOHelper.getFileContentsAsString;
+import static eu.svjatoslav.commons.file.IOHelper.saveToFile;
+import static java.nio.file.StandardWatchEventKinds.*;
+
+
+/**
+ * TODO: What happens when directory gets renamed ? Will event listener reindex all files inside it for processing ?
+ *
+ * The TaskProcessorCommand continuously monitors a specified tasks
+ * directory for new or modified text files, checks if they have a
+ * "TOCOMPUTE:" marker, and if so, adds them to a priority queue to be
+ * processed in priority order. Once processed, results are appended to
+ * the same file.
+ * <p>
+ * Usage:
+ * <pre>
+ * alyverkko-cli mail
+ * </pre>
+ */
+public class TaskProcessorCommand implements Command {
+
+ /**
+ * A command-line parser to handle "mail" command arguments.
+ */
+ final Parser parser = new Parser();
+
+ /**
+ * Optional CLI argument for specifying a configuration file path.
+ */
+ public FileOption configFileOption = parser.add(new FileOption("Configuration file path"))
+ .addAliases("--config", "-c")
+ .mustExist();
+
+ /**
+ * The library of available models, constructed from configuration.
+ */
+ ModelLibrary modelLibrary;
+
+ /**
+ * The WatchService instance for monitoring file system changes in
+ * the mail directory.
+ */
+ private WatchService directoryWatcher;
+
+ /**
+ * The directory that we continuously watch for new tasks.
+ */
+ File taskDirectory;
+
+ /**
+ * Priority queue of tasks to process, sorted by priority and a
+ * random tiebreaker.
+ */
+ private final PriorityQueue<TaskQueueEntry> taskQueue;
+
+ public TaskProcessorCommand() {
+ Comparator<TaskQueueEntry> comparator = (a, b) -> {
+ int priorityCompare = Integer.compare(b.priority, a.priority);
+ if (priorityCompare != 0) {
+ return priorityCompare;
+ }
+ return a.tiebreaker.compareTo(b.tiebreaker);
+ };
+ this.taskQueue = new PriorityQueue<>(comparator);
+ }
+
+ /**
+ * @return the name of this command, i.e., "mail".
+ */
+ @Override
+ public String getCommandName() {
+ return "process";
+ }
+
+ /**
+ * Executes the "mail" command, loading configuration, starting a
+ * WatchService on the mail directory, adding existing files to the
+ * task queue, and processing tasks in priority order.
+ *
+ * @param cliArguments the command-line arguments following the "mail" subcommand.
+ * @throws IOException if reading/writing tasks fails.
+ * @throws InterruptedException if the WatchService is interrupted.
+ */
+ @Override
+ public void executeCommand(String[] cliArguments) throws IOException, InterruptedException {
+ if (!parser.parse(cliArguments)) {
+ System.out.println("Failed to parse commandline arguments");
+ parser.showHelp();
+ return;
+ }
+
+ configuration = ConfigurationHelper.loadConfiguration(ConfigurationHelper.getConfigurationFile(configFileOption));
+ if (configuration == null) {
+ System.out.println("Failed to load configuration file");
+ return;
+ }
+
+ modelLibrary = new ModelLibrary(configuration.getModelsDirectory(), configuration.getModels());
+ taskDirectory = configuration.getTaskDirectory();
+
+ // Set up a directory watch service
+ initializeFileWatcher();
+
+ // Add all existing mail files to the queue
+ initialTaskScanAndReply();
+
+ System.out.println("Mail correspondent running. Press CTRL+c to terminate.");
+
+ // Main loop: process tasks from the queue in priority order
+ while (true) {
+ // Process the highest priority task if available
+ if (!taskQueue.isEmpty()) processTask(taskQueue.poll());
+
+ // Check for filesystem events
+ WatchKey key = directoryWatcher.poll();
+
+ // Sleep briefly to allow the file to be fully written
+ Thread.sleep(1000);
+
+ if (key != null) {
+ System.out.println("Detected filesystem events in mail directory. Processing ... ");
+ processDetectedFilesystemEvents(key);
+
+ if (!key.reset()) {
+ break;
+ }
+ }
+
+ }
+
+ directoryWatcher.close();
+ }
+
+ private void initialTaskScanAndReply() throws IOException {
+ Files.walk(taskDirectory.toPath())
+ .filter(path -> Files.isRegularFile(path))
+ .forEach(path -> {
+ try {
+ considerFileForQueuing(path);
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+ });
+ }
+
+ /**
+ * Checks if a file needs to be processed by verifying that it:
+ * 1) is not hidden,
+ * 2) is a regular file,
+ * 3) starts with "TOCOMPUTE:" in the first line.
+ *
+ * @param file the file to inspect.
+ * @return true if the file meets the criteria for AI processing.
+ * @throws IOException if reading the file fails.
+ */
+ private boolean isMailProcessingNeeded(File file) throws IOException {
+ // ignore hidden files
+ if (file.getName().startsWith(".")) {
+ return false;
+ }
+
+ // Ensure the file exists
+ if (!file.exists()) {
+ return false;
+ }
+
+ // Check if it's a regular file
+ if (!file.isFile()) {
+ return false;
+ }
+
+ // Ensure the first line says "TOCOMPUTE:"
+ return fileHasToComputeMarker(file);
+ }
+
+ /**
+ * Inspects the first line of the file to see if it starts with "TOCOMPUTE:".
+ *
+ * @param file the file to read.
+ * @return true if the file's first line starts with "TOCOMPUTE:".
+ * @throws IOException if file reading fails.
+ */
+ private static boolean fileHasToComputeMarker(File file) throws IOException {
+ String firstLine = getFirstLine(file);
+ return firstLine != null && firstLine.startsWith("TOCOMPUTE:");
+ }
+
+ private static String getFirstLine(File file) throws IOException {
+ try (BufferedReader reader = new BufferedReader(new FileReader(file))) {
+ return reader.readLine();
+ }
+ }
+
+ private static void saveAiResponseToFile(File file, Task task, String aiResponse) throws IOException {
+ StringBuilder resultFileContent = new StringBuilder();
+
+ // The First line should be "DONE:" with settings used to process this file
+ resultFileContent.append(getDoneLine(task));
+
+ resultFileContent.append("* USER:\n");
+
+ // Append the original user prompt (after the first line)
+ resultFileContent.append(task.userPrompt).append("\n");
+
+ // Append the AI response block
+ resultFileContent
+ .append("* ASSISTANT:\n")
+ .append(aiResponse)
+ .append("\n");
+
+ File newFile = new File(file.getParentFile(), "DONE: " + file.getName());
+ saveToFile(newFile, resultFileContent.toString());
+
+ if (!file.delete()) {
+ System.err.println("Failed to delete original file: " + file.getAbsolutePath());
+ }
+ }
+
+ /**
+ * Processes a task by reading the file, building the MailQuery,
+ * running the AI query, and saving the response.
+ *
+ * @param entry the task entry containing the file path and priority.
+ */
+ private void processTask(TaskQueueEntry entry) throws IOException {
+ Path filePath = entry.getFilePath();
+ File file = filePath.toFile();
+
+ if (!isMailProcessingNeeded(file)) {
+ System.out.println("Ignoring file: " + filePath.getFileName() + " (does not need processing now)");
+ return;
+ }
+
+ try {
+ Task task = buildMailQueryFromTaskFile(file);
+ TaskProcess aiTask = new TaskProcess(task);
+ String aiGeneratedResponse = aiTask.runAiQuery();
+
+ saveAiResponseToFile(file, task, aiGeneratedResponse);
+ } catch (IOException | InterruptedException | RuntimeException e) {
+ e.printStackTrace();
+ }
+ }
+
+
+ /**
+ * Builds a string for the first line of the output file indicating
+ * that processing is done.
+ *
+ * @param task the query that was processed.
+ * @return a string for the first line.
+ */
+ private static String getDoneLine(Task task) {
+ return "DONE: skill=" + task.skillName +
+ " model=" + task.model.alias +
+ " duration=" + getDuration(task.startTimeMillis, task.endTimeMillis) + "\n";
+ }
+
+
+ private static String getDuration(long startTimeMillis, long endTimeMillis) {
+
+ long durationSeconds = (endTimeMillis - startTimeMillis) / 1000;
+
+ if (durationSeconds < 180) return durationSeconds + "s";
+
+ long durationMinutes = durationSeconds / 60;
+ if (durationMinutes < 180) return durationMinutes + "m";
+
+ long durationHours = durationMinutes / 60;
+ return durationHours + "h";
+ }
+
+ /**
+ * Builds a MailQuery object from the contents of a file.
+ *
+ * @param file the file to read.
+ * @return the constructed MailQuery.
+ * @throws IOException if reading the file fails.
+ */
+ private Task buildMailQueryFromTaskFile(File file) throws IOException {
+ Task result = new Task();
+
+ String inputFileContent = getFileContentsAsString(file);
+ int firstNewLineIndex = inputFileContent.indexOf('\n');
+ if (firstNewLineIndex == -1) {
+ throw new IllegalArgumentException("Input file is only one line long.");
+ }
+
+ String firstLine = inputFileContent.substring(0, firstNewLineIndex);
+ Map<String, String> fileProcessingSettings = parseSettings(firstLine);
+
+ // The rest of the file is the user prompt
+ result.userPrompt = inputFileContent.substring(firstNewLineIndex + 1);
+
+ // Set system prompt
+ result.skillName = fileProcessingSettings.getOrDefault("skill", "default");
+ SkillConfig skill = configuration.getSkillByName(result.skillName);
+ result.systemPrompt = skill.getPrompt();
+ result.skill = skill;
+
+ // Set AI model
+ String modelAlias = fileProcessingSettings.getOrDefault("model", "default");
+ Optional<Model> modelOptional = modelLibrary.findModelByAlias(modelAlias);
+ if (!modelOptional.isPresent()) {
+ throw new IllegalArgumentException("Model with alias '" + modelAlias + "' not found.");
+ }
+ result.model = modelOptional.get();
+
+ // Set priority
+ String priorityStr = fileProcessingSettings.get("priority");
+ result.priority = 0;
+ if (priorityStr != null) {
+ try {
+ result.priority = Integer.parseInt(priorityStr);
+ } catch (NumberFormatException e) {
+ System.err.println("Invalid priority in file: " + priorityStr);
+ }
+ }
+
+ return result;
+ }
+
+
+ /**
+ * Parses the "TOCOMPUTE:" line, which should look like:
+ * <pre>TOCOMPUTE: key1=value1 key2=value2 ...</pre>
+ *
+ * @param toComputeLine the line beginning with "TOCOMPUTE:".
+ * @return a map of settings derived from that line.
+ */
+ private Map<String, String> parseSettings(String toComputeLine) {
+ if (!toComputeLine.startsWith("TOCOMPUTE:")) {
+ throw new IllegalArgumentException("Invalid TOCOMPUTE line: " + toComputeLine);
+ }
+
+ // If there's nothing beyond "TOCOMPUTE:", just return an empty map
+ if (toComputeLine.length() <= "TOCOMPUTE: ".length()) {
+ return new HashMap<>();
+ }
+
+ // Example format: "TOCOMPUTE: prompt=writer model=mistral"
+ String[] parts = toComputeLine.substring("TOCOMPUTE: ".length()).split("\\s+");
+ Map<String, String> settings = new HashMap<>();
+
+ for (String part : parts) {
+ String[] keyValue = part.split("=");
+ if (keyValue.length == 2) {
+ settings.put(keyValue[0], keyValue[1]);
+ }
+ }
+ return settings;
+ }
+
+ /**
+ * Handles the filesystem events from the WatchService (e.g., file creation, modification, or deletion),
+ * and updates the task queue accordingly.
+ *
+ * @param key the watch key containing the events.
+ * @throws IOException if file processing fails.
+ */
+ private void processDetectedFilesystemEvents(WatchKey key) throws IOException {
+ Path dir = (Path) key.watchable();
+ for (WatchEvent<?> event : key.pollEvents()) {
+ WatchEvent.Kind<?> kind = event.kind();
+ if (kind != ENTRY_CREATE && kind != ENTRY_MODIFY && kind != ENTRY_DELETE) {
+ continue;
+ }
+ @SuppressWarnings("unchecked")
+ WatchEvent<Path> ev = (WatchEvent<Path>) event;
+ Path filename = ev.context();
+ Path fullPath = dir.resolve(filename);
+ System.out.printf("Event: %s – %s%n", kind.name(), fullPath);
+ if (kind == ENTRY_DELETE) {
+ // Remove any existing tasks for this file
+ removeTasksForFile(fullPath);
+ continue;
+ }
+ // Handle directory creation
+ if (Files.isDirectory(fullPath)) {
+ if (kind == ENTRY_CREATE) {
+ // Register the new directory and its subdirectories for monitoring
+ registerAllSubdirectories(fullPath);
+ // Scan the new directory for existing files to process
+ Files.walk(fullPath)
+ .filter(path -> {
+ try {
+ return Files.isRegularFile(path) && !Files.isHidden(path);
+ } catch (IOException e) {
+ System.err.println("Failed to check if file is hidden: " + path + " - " + e.getMessage());
+ return false; // Skip files that cause errors
+ }
+ })
+ .forEach(path -> {
+ try {
+ considerFileForQueuing(path);
+ } catch (IOException e) {
+ System.err.println("Failed to process file in new directory: " + path + " - " + e.getMessage());
+ }
+ });
+ }
+ continue;
+ }
+ // Handle file events
+ if (kind == ENTRY_MODIFY) {
+ // Remove existing tasks for this file before adding new ones
+ removeTasksForFile(fullPath);
+ }
+ // Check if it's a regular, non-hidden file and needs processing
+ try {
+ if (Files.isRegularFile(fullPath) && !Files.isHidden(fullPath)) {
+ considerFileForQueuing(fullPath);
+ }
+ } catch (IOException e) {
+ System.err.println("Failed to check file: " + fullPath + " - " + e.getMessage());
+ }
+ }
+ }
+
+ private void initializeFileWatcher() throws IOException {
+ this.directoryWatcher = FileSystems.getDefault().newWatchService();
+ registerAllSubdirectories(taskDirectory.toPath());
+ }
+
+ private void registerAllSubdirectories(Path path) {
+ try {
+ System.out.println("Registering directory for watch service: " + path);
+ path.register(directoryWatcher, ENTRY_CREATE, ENTRY_MODIFY);
+ if (Files.isDirectory(path)) {
+ try (DirectoryStream<Path> stream = Files.newDirectoryStream(path)) {
+ for (Path entry : stream) {
+ if (Files.isDirectory(entry)) {
+ registerAllSubdirectories(entry);
+ }
+ }
+ }
+ }
+ } catch (IOException e) {
+ System.err.println("Failed to register directory: " + path + " - " + e.getMessage());
+ }
+ }
+
+ /**
+ * Adds a file to the task queue if it needs processing.
+ *
+ * @param filePath the path to the file to check.
+ * @throws IOException if reading the first line fails.
+ */
+ private void considerFileForQueuing(Path filePath) throws IOException {
+ File file = filePath.toFile();
+ if (!isMailProcessingNeeded(file)) return;
+
+ String firstLine = getFirstLine(file);
+ Map<String, String> settings = parseSettings(firstLine);
+ int priority = 0;
+ String priorityStr = settings.get("priority");
+ if (priorityStr != null) {
+ try {
+ priority = Integer.parseInt(priorityStr);
+ } catch (NumberFormatException e) {
+ System.err.println("Invalid priority in file " + filePath.getFileName() + ": " + priorityStr);
+ }
+ }
+ taskQueue.offer(new TaskQueueEntry(filePath, priority));
+ }
+
+ /**
+ * A static nested class representing a task in the queue, with a
+ * priority and a tiebreaker for sorting.
+ */
+ private static class TaskQueueEntry implements Comparable<TaskQueueEntry> {
+ private final Path filePath;
+ private final int priority;
+ private final String tiebreaker;
+
+ public TaskQueueEntry(Path filePath, int priority) {
+ this.filePath = filePath;
+ this.priority = priority;
+ this.tiebreaker = UUID.randomUUID().toString();
+ }
+
+ public Path getFilePath() {
+ return filePath;
+ }
+
+ public int getPriority() {
+ return priority;
+ }
+
+ public String getTiebreaker() {
+ return tiebreaker;
+ }
+
+ @Override
+ public int compareTo(TaskQueueEntry other) {
+ int priorityCompare = Integer.compare(this.priority, other.priority);
+ if (priorityCompare != 0) {
+ return -priorityCompare; // higher priority first
+ }
+ return this.tiebreaker.compareTo(other.tiebreaker);
+ }
+ }
+
+ /**
+ * Removes all tasks from the queue that match the given file path.
+ * This is done by draining the queue into a list, filtering out the matching entries,
+ * and re-adding the remaining ones back into the queue.
+ *
+ * @param filePath the file path to match and remove from the queue.
+ */
+ private void removeTasksForFile(Path filePath) {
+ List<TaskQueueEntry> remaining = new ArrayList<>();
+
+ // Drain all elements into a list
+ while (!taskQueue.isEmpty()) {
+ TaskQueueEntry entry = taskQueue.poll();
+ if (!entry.getFilePath().equals(filePath)) {
+ remaining.add(entry);
+ }
+ }
+
+ // Re-add all remaining entries back to the queue
+ for (TaskQueueEntry entry : remaining) {
+ taskQueue.offer(entry);
+ }
+ }
+
+}
--- /dev/null
+/**
+ * <p>This subpackage implements the mail-based AI task processing functionality. It watches mail directories for
+ * new/modified files with specific markers and processes them using AI models.
+ * <p>Key components:
+ * <ul>
+ * <li>File monitoring with WatchService</li>
+ * <li>Prompt parsing and execution logic</li>
+ * <li>Query object for storing processing parameters</li>
+ * </ul>
+ */
+
+package eu.svjatoslav.alyverkko_cli.commands.task_processor;
\ No newline at end of file
--- /dev/null
+package eu.svjatoslav.alyverkko_cli.configuration;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.Data;
+
+import java.io.*;
+import java.util.List;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
+
+
+/**
+ * <p>Central configuration class storing all application parameters.
+ * This class is serialized to YAML format for user editing and persistence.
+ * <p>Configuration parameters include:
+ * <ul>
+ * <li>Model and prompt directories</li>
+ * <li>Performance tuning parameters</li>
+ * <li>Model-specific configurations</li>
+ * </ul>
+ * <p>All paths are resolved relative to the user's home directory by default, but can be customized. The class provides
+ * direct access to prompt content for AI query construction.
+ */
+@Data
+public class Configuration {
+
+ /**
+ * Directory where AI tasks (mail) are placed and discovered.
+ */
+ @JsonProperty("task_directory")
+ private File taskDirectory;
+
+ /**
+ * Directory that contains AI model files in GGUF format.
+ */
+ @JsonProperty("models_directory")
+ private File modelsDirectory;
+
+ /**
+ * The default "temperature" used by the AI for creative/deterministic
+ * tradeoff. Ranges roughly between 0 and 3.
+ */
+ @JsonProperty("default_temperature")
+ private Float defaultTemperature;
+
+ /**
+ * Default top-p value used when not specified elsewhere.
+ * Controls diversity of sampling (0.0-1.0, where 1.0 means no restriction).
+ */
+ @JsonProperty("default_top_p")
+ private Float defaultTopP;
+
+ /**
+ * Default global Top-K value used when not specified elsewhere.
+ */
+ @JsonProperty("default_top_k")
+ private Float defaultTopK = 30f;
+
+ /**
+ * Global minimum-p threshold default (applies if none set).
+ */
+ @JsonProperty("default_min_p")
+ private Float defaultMinP = 0f;
+
+ /**
+ * Default repeat penalty used when not specified elsewhere.
+ * Controls repetition reduction (typically 0.8-2.0, where 1.0 means no penalty).
+ */
+ @JsonProperty("default_repeat_penalty")
+ private Float defaultRepeatPenalty;
+
+ /**
+ * The filesystem path to the llama-cli executable, which processes
+ * AI tasks via llama.cpp.
+ */
+ @JsonProperty("llama_cli_path")
+ private File llamaCliPath;
+
+ /**
+ * Number of CPU threads used for input prompt processing.
+ */
+ @JsonProperty("batch_thread_count")
+ private Integer batchThreadCount;
+
+ /**
+ * Number of CPU threads used for AI inference.
+ */
+ @JsonProperty("thread_count")
+ private Integer threadCount;
+
+ /**
+ * Directory containing text prompt files. Each file is a separate
+ * "prompt" by alias (the filename minus ".txt").
+ */
+ @JsonProperty("skills_directory")
+ private File skillsDirectory;
+
+ /**
+ * The list of models defined in this configuration.
+ */
+ private List<ConfigurationModel> models;
+
+
+ /**
+ * Retrieves the contents of a prompt file by alias, e.g. "writer"
+ * maps to "writer.txt" in the prompt's directory.
+ *
+ * @param skillName the name of the prompt file (without ".txt").
+ * @return the full-text content of the prompt file.
+ * @throws IOException if reading the prompt file fails.
+ */
+ public SkillConfig getSkillByName(String skillName) throws IOException {
+ File promptFile = new File(skillsDirectory, skillName + ".yaml");
+ ObjectMapper mapper = new ObjectMapper(new YAMLFactory());
+ return mapper.readValue(promptFile, SkillConfig.class);
+ }
+
+}
--- /dev/null
+package eu.svjatoslav.alyverkko_cli.configuration;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
+import eu.svjatoslav.commons.cli_helper.parameter_parser.parameter.FileOption;
+
+import java.io.File;
+import java.io.IOException;
+
+/**
+ * <p>Helper class for configuration file operations. Provides methods for loading configurations
+ * and determining the default configuration file path in the user's home directory.
+ * <p>Key functionality includes:
+ * <ul>
+ * <li>Configuration file path resolution</li>
+ * <li>YAML deserialization</li>
+ * <li>Error handling for missing configurations</li>
+ * </ul>
+ */
+public class ConfigurationHelper {
+
+ /**
+ * The default path for the YAML config file, typically under the user's home directory.
+ */
+ public static final String DEFAULT_CONFIG_FILE_PATH = "~/.config/alyverkko-cli/alyverkko-cli.yaml".replaceFirst("^~", System.getProperty("user.home"));
+
+ /**
+ * Loads the configuration from a given file, or from the default
+ * path if {@code configFile} is null.
+ *
+ * @param configFile the file containing the YAML config; may be null.
+ * @return the {@link Configuration} object, or null if not found/invalid.
+ * @throws IOException if file I/O fails during reading.
+ */
+ public static Configuration loadConfiguration(File configFile) throws IOException {
+
+ if (!configFile.exists()) {
+ System.err.println("Configuration file not found: " + configFile);
+ return null;
+ }
+
+ ObjectMapper mapper = new ObjectMapper(new YAMLFactory());
+ return mapper.readValue(configFile, Configuration.class);
+ }
+
+ /**
+ * Returns the configuration file from the given option, or the default path if not present.
+ * @param configFileOption the CLI option for the config file.
+ * @return the configuration file to load.
+ */
+ public static File getConfigurationFile(FileOption configFileOption) {
+ if (configFileOption != null)
+ if (configFileOption.isPresent())
+ return configFileOption.getValue();
+
+ return new File(DEFAULT_CONFIG_FILE_PATH);
+ }
+}
--- /dev/null
+package eu.svjatoslav.alyverkko_cli.configuration;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.Data;
+
+/**
+ * Represents a single AI model configuration entry, including alias,
+ * path to the model file, token context size, and an optional
+ * end-of-text marker.
+ */
+@Data
+public class ConfigurationModel {
+
+ /**
+ * A short name for the model, e.g., "default" or "mistral".
+ */
+ private String alias;
+
+ /**
+ * Model-specific temperature value overriding global default.
+ */
+ private Float temperature;
+
+ /**
+ * Model-specific top-p value overriding global default.
+ */
+ @JsonProperty("top_p")
+ private Float topP;
+
+ @JsonProperty("min_p")
+ private Float minP;
+
+ @JsonProperty("top_k")
+ private Float topK;
+
+ /**
+ * Model-specific repeat penalty value overriding global default.
+ */
+ @JsonProperty("repeat_penalty")
+ private Float repeatPenalty;
+
+
+ /**
+ * The path to the model file (GGUF, etc.), relative to
+ * {@link Configuration#getModelsDirectory()} or fully qualified.
+ */
+ @JsonProperty("filesystem_path")
+ private String filesystemPath;
+
+ /**
+ * The maximum context size the model supports, in tokens.
+ */
+ @JsonProperty("context_size_tokens")
+ private int contextSizeTokens;
+
+ /**
+ * Optional text marker signifying the end of text for this model.
+ * If non-null, it will be used to strip trailing tokens from the AI response.
+ */
+ @JsonProperty("end_of_text_marker")
+ private String endOfTextMarker;
+}
--- /dev/null
+package eu.svjatoslav.alyverkko_cli.configuration;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.Data;
+
+@Data
+public class SkillConfig {
+
+ private String prompt;
+
+ private Float temperature;
+
+ /**
+ * Skill-specific top-p value overriding model/global defaults.
+ */
+ private Float topP;
+
+ @JsonProperty("top_k")
+ private Float topK;
+
+ @JsonProperty("min_p")
+ private Float minP;
+
+ /**
+ * Skill-specific repeat penalty value overriding model/global defaults.
+ */
+ private Float repeatPenalty;
+
+}
\ No newline at end of file
--- /dev/null
+/**
+ * <p>This package handles the configuration system for the Älyverkko CLI application.
+ * It provides classes for storing and retrieving application-wide settings, including
+ * model configurations, directory paths, and performance parameters.
+ * <p>Configuration is stored in YAML format and includes:
+ * <ul>
+ * <li>Model directory paths</li>
+ * <li>Mail task directories</li>
+ * <li>Performance settings (thread counts, temperature)</li>
+ * </ul>
+ */
+
+package eu.svjatoslav.alyverkko_cli.configuration;
\ No newline at end of file
--- /dev/null
+package eu.svjatoslav.alyverkko_cli.model;
+
+import java.io.File;
+
+/**
+ * <p>Represents an AI model stored on the filesystem with metadata about its capabilities and identification.
+ * This class serves as a lightweight container for model information, enabling quick lookup and validation.
+ * <p>Models are typically discovered through configuration files and stored in the ModelLibrary for easy access.
+ * <p>Key fields include:
+ * <ul>
+ * <li>filesystemPath - Location of the model file</li>
+ * <li>contextSizeTokens - Maximum token capacity for this model</li>
+ * <li>alias - User-friendly identifier for the model</li>
+ * <li>endOfTextMarker - Optional response completion marker</li>
+ * </ul>
+ */
+public class Model {
+
+ /**
+ * The path to the model file on the filesystem.
+ */
+ public final File filesystemPath;
+
+ /**
+ * The size of the context (in tokens) that this model is able to handle.
+ */
+ public final int contextSizeTokens;
+
+ /**
+ * A user-friendly alias for the model, e.g. "default" or "mistral".
+ */
+ public final String alias;
+
+ /**
+ * An optional marker indicating end of the AI-generated text (e.g., "###").
+ * If non-null, it can be used to detect where the model has finished answering.
+ */
+ public final String endOfTextMarker;
+
+ /**
+ * Model-specific temperature value (null if not configured).
+ */
+ public Float temperature;
+
+ /**
+ * Model-specific top-p value (null if not configured).
+ */
+ public Float topP;
+
+ /**
+ * Model-specific Top-K parameter controlling token selection (null if not configured).
+ */
+ public Float topK;
+
+ /**
+ * Minimum probability threshold for candidate tokens (null if not configured).
+ */
+ public Float minP;
+
+ /**
+ * Model-specific repeat penalty value (null if not configured).
+ */
+ public Float repeatPenalty;
+
+ /**
+ * Constructs a {@link Model} instance with all hyperparameters.
+ *
+ * @param filesystemPath The path to the model file on the filesystem.
+ * @param contextSizeTokens Maximum token capacity of this model.
+ * @param modelAlias User-friendly identifier for the model.
+ * @param endOfTextMarker Optional response completion marker.
+ * @param temperature Sampling temperature (higher = more creative)
+ * @param topP Nucleus sampling threshold (0.0-1.0)
+ * @param repeatPenalty Penalty for token repetition (>0.0)
+ * @param topK Token selection cutoff for Top-K sampling (>=1)
+ * @param minP Minimum relative probability threshold (0.0-1.0)
+ */
+ public Model(File filesystemPath, int contextSizeTokens,
+ String modelAlias, String endOfTextMarker,
+ Float temperature, Float topP, Float repeatPenalty,
+ Float topK, Float minP) {
+ this.filesystemPath = filesystemPath;
+ this.contextSizeTokens = contextSizeTokens;
+ this.alias = modelAlias;
+ this.endOfTextMarker = endOfTextMarker;
+ this.temperature = temperature;
+ this.topP = topP;
+ this.repeatPenalty = repeatPenalty;
+ this.topK = topK;
+ this.minP = minP;
+ }
+
+ /**
+ * <p>Prints the model's metadata to standard output in a consistent format. This includes the model's alias,
+ * filesystem path, and context token capacity. The output format is designed to be both human-readable and
+ * machine-parsable when needed.
+ * <p>Typical output:
+ * <pre>
+ * Model: default
+ * Path: /path/to/model.gguf
+ * Context size: 32768
+ * </pre>
+ */
+ public void printModelDetails() {
+ System.out.println("Model: " + alias);
+ System.out.println(" Path: " + filesystemPath);
+ System.out.println(" Context size: " + contextSizeTokens);
+ }
+}
--- /dev/null
+package eu.svjatoslav.alyverkko_cli.model;
+
+import eu.svjatoslav.alyverkko_cli.Utils;
+import eu.svjatoslav.alyverkko_cli.configuration.ConfigurationModel;
+
+import java.io.File;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * A container (library) for multiple AI models, providing
+ * functionality for adding and retrieving models by alias.
+ */
+public class ModelLibrary {
+
+ /**
+ * The list of all successfully loaded models in this library.
+ */
+ private final List<Model> models;
+
+ /**
+ * The default model for this library (e.g., the first successfully
+ * loaded model in the list).
+ */
+ private static Model defaultModel;
+
+ /**
+ * Base directory containing the model files.
+ */
+ private final File modelsBaseDirectory;
+
+ /**
+ * Constructs a library of AI models from the provided list of
+ * {@link ConfigurationModel}s, ignoring those whose paths do not exist.
+ *
+ * @param modelsBaseDirectory the root directory where model files are stored.
+ * @param configModels a list of model configurations.
+ */
+ public ModelLibrary(File modelsBaseDirectory, List<ConfigurationModel> configModels) {
+ this.modelsBaseDirectory = modelsBaseDirectory;
+ this.models = new ArrayList<>();
+
+ for (ConfigurationModel configModel : configModels) {
+ addModelFromConfig(configModel);
+ }
+
+ if (models.isEmpty()) {
+ throw new RuntimeException("No models are defined!");
+ }
+
+ defaultModel = models.get(0);
+ }
+
+ /**
+ * Attempts to construct a {@link Model} from configuration, verifying file existence.
+ */
+ private void addModelFromConfig(ConfigurationModel configModel) {
+ File modelFile = new File(modelsBaseDirectory, configModel.getFilesystemPath());
+ if (!modelFile.exists()) {
+ Utils.printRedMessageToConsole("WARN: Model file not found: " + modelFile.getAbsolutePath() + " . Skipping model.");
+ return;
+ }
+
+ addModel(new Model(
+ modelFile,
+ configModel.getContextSizeTokens(),
+ configModel.getAlias(),
+ configModel.getEndOfTextMarker(),
+
+ configModel.getTemperature(),
+ configModel.getTopP(),
+ configModel.getRepeatPenalty(),
+
+ configModel.getTopK(),
+ configModel.getMinP()
+ ));
+
+ }
+
+ /**
+ * Adds a model to the library if no model with the same alias
+ * already exists.
+ *
+ * @param model the model to add.
+ * @throws RuntimeException if a model with the same alias already exists.
+ */
+ public void addModel(Model model) {
+ if (findModelByAlias(model.alias).isPresent()) {
+ throw new RuntimeException("Model with alias \"" + model.alias + "\" already exists!");
+ }
+ models.add(model);
+ }
+
+ /**
+ * @return the list of loaded models in this library.
+ */
+ public List<Model> getModels() {
+ return models;
+ }
+
+ /**
+ * Finds a model by its alias in this library.
+ *
+ * @param alias the model alias to look for.
+ * @return an {@link Optional} describing the found model, or empty if none match.
+ */
+ public Optional<Model> findModelByAlias(String alias) {
+ return models.stream()
+ .filter(model -> model.alias.equals(alias))
+ .findFirst();
+ }
+
+ /**
+ * @return the default model (first loaded model).
+ */
+ public Model getDefaultModel() {
+ return defaultModel;
+ }
+
+ /**
+ * Prints the details of each model in the library to standard output.
+ */
+ public void printModels() {
+ System.out.println("Available models:\n");
+ for (Model model : models) {
+ model.printModelDetails();
+ System.out.println();
+ }
+ }
+}
\ No newline at end of file
--- /dev/null
+/**
+ * <p>This package defines the model management system for the Älyverkko CLI.
+ * It includes classes for representing AI models and maintaining a library of available models.
+ * <p>Key features:
+ * <ul>
+ * <li>Model metadata storage and validation</li>
+ * <li>Default model selection and management</li>
+ * <li>File system integration for model discovery</li>
+ * </ul>
+ */
+
+package eu.svjatoslav.alyverkko_cli.model;
\ No newline at end of file
--- /dev/null
+/**
+ * <p>This package contains the core components of the Älyverkko CLI application, including the main entry point,
+ * command interfaces, and utility classes. It serves as the central hub for orchestrating subcommands and core
+ * application behavior.
+ * <p>Key responsibilities include:
+ * <ul>
+ * <li>Command registration and execution</li>
+ * <li>Configuration loading and management</li>
+ * <li>Basic utility functions for colored console output</li>
+ * </ul>
+ *
+ */
+
+package eu.svjatoslav.alyverkko_cli;
\ No newline at end of file
--- /dev/null
+#!/bin/bash
+cd "${0%/*}"; if [ "$1" != "T" ]; then gnome-terminal -e "'$0' T"; exit; fi;
+cd ..
+
+read -p "Enter the topic name: " TOPIC_NAME
+
+alyverkko-cli joinfiles -t "$TOPIC_NAME" --src-dir . --pattern "*.org"
+alyverkko-cli joinfiles -t "$TOPIC_NAME" --src-dir . --pattern "*.java"
+alyverkko-cli joinfiles -t "$TOPIC_NAME" --src-dir . --pattern "implement*"
+alyverkko-cli joinfiles -t "$TOPIC_NAME" --src-dir . --pattern "install"
+alyverkko-cli joinfiles -t "$TOPIC_NAME" --src-dir . --pattern "uninstall"
+alyverkko-cli joinfiles -t "$TOPIC_NAME" --edit
--- /dev/null
+#!/bin/bash
+
+# This script launches IntelliJ IDEA with the current project
+# directory. The script is designed to be run by double-clicking it in
+# the GNOME Nautilus file manager.
+
+# First, we change the current working directory to the directory of
+# the script.
+
+# "${0%/*}" gives us the path of the script itself, without the
+# script's filename.
+
+# This command basically tells the system "change the current
+# directory to the directory containing this script".
+
+cd "${0%/*}"
+
+# Then, we move up one directory level.
+# The ".." tells the system to go to the parent directory of the current directory.
+# This is done because we assume that the project directory is one level up from the script.
+cd ..
+
+# Now, we use the 'setsid' command to start a new session and run
+# IntelliJ IDEA in the background. 'setsid' is a UNIX command that
+# runs a program in a new session.
+
+# The command 'idea .' opens IntelliJ IDEA with the current directory
+# as the project directory. The '&' at the end is a UNIX command that
+# runs the process in the background. The '> /dev/null' part tells
+# the system to redirect all output (both stdout and stderr, denoted
+# by '&') that would normally go to the terminal to go to /dev/null
+# instead, which is a special file that discards all data written to
+# it.
+
+setsid idea . &>/dev/null &
+
+# The 'disown' command is a shell built-in that removes a shell job
+# from the shell's active list. Therefore, the shell will not send a
+# SIGHUP to this particular job when the shell session is terminated.
+
+# '-h' option specifies that if the shell receives a SIGHUP, it also
+# doesn't send a SIGHUP to the job.
+
+# '$!' is a shell special parameter that expands to the process ID of
+# the most recent background job.
+disown -h $!
+
+
+sleep 2
+
+# Finally, we use the 'exit' command to terminate the shell script.
+# This command tells the system to close the terminal window after
+# IntelliJ IDEA has been opened.
+exit
--- /dev/null
+#!/bin/bash
+cd "${0%/*}"; if [ "$1" != "T" ]; then gnome-terminal -e "'$0' T"; exit; fi;
+
+cd ..
+
+# Build the project jar file and the apidocs.
+mvn clean package
+
+# Export org to html using emacs in batch mode
+(
+ cd doc/
+
+ rm -f index.html
+ emacs --batch -l ~/.emacs --visit=index.org --funcall=org-html-export-to-html --kill
+
+ #rm setup.html
+ #emacs --batch -l ~/.emacs --visit=setup.org --funcall=org-html-export-to-html --kill
+)
+
+# Generate class diagrams. See: https://www3.svjatoslav.eu/projects/javainspect/
+rm -rf doc/graphs/
+mkdir -p doc/graphs/
+javainspect -j target/alyverkko-cli-*-SNAPSHOT.jar -d doc/graphs/ -n "all classes" -t png -ho
+meviz index -w doc/graphs/ -t "Älyverkko CLI program classes"
+
+# Copy the apidocs to the doc folder so that they can be uploaded to the server.
+rm -rf doc/apidocs/
+cp -r target/apidocs/ doc/
+
+# Upload project homepage to the server.
+rsync -avz --delete -e 'ssh -p 10006' doc/ n0@www3.svjatoslav.eu:/mnt/big/projects/alyverkko-cli/
+
+echo ""
+echo "Press ENTER to close this window."
+read
--- /dev/null
+#!/bin/bash
+
+SYSTEMD_SERVICE_FILE="/etc/systemd/system/alyverkko-cli.service"
+
+sudo systemctl stop alyverkko-cli
+sudo systemctl disable alyverkko-cli
+sudo rm "$SYSTEMD_SERVICE_FILE"
+sudo rm -rf /opt/alyverkko-cli/
+
+read -p "Do you want to remove user configuration as well? (y/N) " remove_config
+if [[ $remove_config == [Yy] ]]; then
+ sudo rm -rf "${HOME}/.config/alyverkko-cli"
+fi