Possibility to specify alternative configuration file for selftest command
authorSvjatoslav Agejenko <svjatoslav@svjatoslav.eu>
Mon, 24 Mar 2025 23:21:15 +0000 (01:21 +0200)
committerSvjatoslav Agejenko <svjatoslav@svjatoslav.eu>
Mon, 24 Mar 2025 23:21:15 +0000 (01:21 +0200)
src/main/java/eu/svjatoslav/alyverkko_cli/AiTask.java
src/main/java/eu/svjatoslav/alyverkko_cli/Main.java
src/main/java/eu/svjatoslav/alyverkko_cli/commands/MailCorrespondentCommand.java [deleted file]
src/main/java/eu/svjatoslav/alyverkko_cli/commands/MailQuery.java [deleted file]
src/main/java/eu/svjatoslav/alyverkko_cli/commands/SelftestCommand.java
src/main/java/eu/svjatoslav/alyverkko_cli/commands/mail_correspondant/MailCorrespondentCommand.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/alyverkko_cli/commands/mail_correspondant/MailQuery.java [new file with mode: 0644]

index 73c3ede..fa35763 100644 (file)
@@ -1,6 +1,6 @@
 package eu.svjatoslav.alyverkko_cli;
 
-import eu.svjatoslav.alyverkko_cli.commands.MailQuery;
+import eu.svjatoslav.alyverkko_cli.commands.mail_correspondant.MailQuery;
 
 import java.io.*;
 import java.nio.file.Files;
index f79f341..25fa1fd 100644 (file)
@@ -1,6 +1,7 @@
 package eu.svjatoslav.alyverkko_cli;
 
 import eu.svjatoslav.alyverkko_cli.commands.*;
+import eu.svjatoslav.alyverkko_cli.commands.mail_correspondant.MailCorrespondentCommand;
 import eu.svjatoslav.alyverkko_cli.configuration.Configuration;
 
 import java.io.IOException;
diff --git a/src/main/java/eu/svjatoslav/alyverkko_cli/commands/MailCorrespondentCommand.java b/src/main/java/eu/svjatoslav/alyverkko_cli/commands/MailCorrespondentCommand.java
deleted file mode 100644 (file)
index 32299dd..0000000
+++ /dev/null
@@ -1,344 +0,0 @@
-package eu.svjatoslav.alyverkko_cli.commands;
-
-import eu.svjatoslav.alyverkko_cli.*;
-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.HashMap;
-import java.util.Map;
-import java.util.Optional;
-
-import static eu.svjatoslav.alyverkko_cli.Main.configuration;
-import static eu.svjatoslav.alyverkko_cli.configuration.Configuration.loadConfiguration;
-import static eu.svjatoslav.commons.file.IOHelper.getFileContentsAsString;
-import static eu.svjatoslav.commons.file.IOHelper.saveToFile;
-import static java.nio.file.StandardWatchEventKinds.ENTRY_CREATE;
-import static java.nio.file.StandardWatchEventKinds.ENTRY_MODIFY;
-
-/**
- * The MailCorrespondentCommand continuously monitors a specified mail
- * directory for new or modified text files, checks if they have a
- * "TOCOMPUTE:" marker, and if so, processes them with an AI model.
- * Once processed, results are appended to the same file.
- *
- * Usage:
- * <pre>
- *   alyverkko-cli mail
- * </pre>
- */
-public class MailCorrespondentCommand 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 mailDir;
-
-    /**
-     * @return the name of this command, i.e., "mail".
-     */
-    @Override
-    public String getName() {
-        return "mail";
-    }
-
-    /**
-     * Executes the "mail" command, loading configuration, starting a
-     * WatchService on the mail directory, and running an infinite loop
-     * that processes newly discovered tasks.
-     *
-     * @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 execute(String[] cliArguments) throws IOException, InterruptedException {
-        if (!parser.parse(cliArguments)) {
-            System.out.println("Failed to parse commandline arguments");
-            parser.showHelp();
-            return;
-        }
-
-        configuration = loadConfiguration(configFileOption.isPresent() ? configFileOption.getValue() : null);
-        if (configuration == null) {
-            System.out.println("Failed to load configuration file");
-            return;
-        }
-
-        modelLibrary = new ModelLibrary(configuration.getModelsDirectory(), configuration.getModels());
-        mailDir = configuration.getMailDirectory();
-
-        // Set up directory watch service
-        initializeFileWatcher();
-
-        // Process any existing files that might already be in the directory
-        initialMailScanAndReply();
-
-        System.out.println("Mail correspondent running. Press CTRL+c to terminate.");
-
-        // Main loop: watch for file events
-        while (true) {
-            WatchKey key;
-            try {
-                key = directoryWatcher.take();
-            } catch (InterruptedException e) {
-                System.out.println("Interrupted while waiting for file system events. Exiting.");
-                break;
-            }
-
-            System.out.println("Detected filesystem event.");
-
-            // Sleep briefly to allow the file to be fully written
-            Thread.sleep(1000);
-
-            processDetectedFilesystemEvents(key);
-
-            if (!key.reset()) {
-                break;
-            }
-        }
-
-        directoryWatcher.close();
-    }
-
-    /**
-     * Performs an initial scan of existing files in the mail directory,
-     * processing those that need AI inference (i.e., that start with "TOCOMPUTE:").
-     *
-     * @throws IOException          if reading files fails.
-     * @throws InterruptedException if the thread is interrupted.
-     */
-    private void initialMailScanAndReply() throws IOException, InterruptedException {
-        File[] files = mailDir.listFiles();
-        if (files == null) return;
-
-        for (File file : files) {
-            processMailIfNeeded(file);
-        }
-    }
-
-    /**
-     * 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;
-        }
-
-        // 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 {
-        try (BufferedReader reader = new BufferedReader(new FileReader(file))) {
-            String firstLine = reader.readLine();
-            return firstLine != null && firstLine.startsWith("TOCOMPUTE:");
-        }
-    }
-
-    /**
-     * Processes a file if it has the "TOCOMPUTE:" marker, running an AI
-     * query and appending the result to the file. Otherwise logs that
-     * it's being ignored.
-     *
-     * @param file the file to possibly process.
-     * @throws IOException          if reading/writing the file fails.
-     * @throws InterruptedException if the AI query is interrupted.
-     */
-    private void processMailIfNeeded(File file) throws IOException, InterruptedException {
-        if (!isMailProcessingNeeded(file)) {
-            System.out.println("Ignoring file: " + file.getName() + " (does not need processing for now)");
-            return;
-        }
-
-        System.out.println("\nReplying to mail: " + file.getName());
-
-        // Read the mail content
-        String inputFileContent = getFileContentsAsString(file);
-
-        // Parse the relevant data into a MailQuery object
-        MailQuery mailQuery = parseInputFileContent(inputFileContent);
-
-        // Create an AiTask and run the query
-        AiTask aiTask = new AiTask(mailQuery);
-        String aiGeneratedResponse = aiTask.runAiQuery();
-
-        // Build new content
-        StringBuilder resultFileContent = new StringBuilder();
-
-        // Ensure the user prompt block is labeled if it isn't already
-        if (!mailQuery.userPrompt.startsWith("* USER:\n")) {
-            resultFileContent.append("* USER:\n");
-        }
-        resultFileContent.append(mailQuery.userPrompt).append("\n");
-
-        // Append the AI response block
-        resultFileContent
-                .append("* ASSISTANT:\n")
-                .append(aiGeneratedResponse)
-                .append("\n");
-
-        // Write the combined result back to the same file
-        saveToFile(file, resultFileContent.toString());
-    }
-
-    /**
-     * Converts the raw file content (including the line beginning with "TOCOMPUTE:")
-     * into a {@link MailQuery} object that the AI can process.
-     *
-     * @param inputFileContent the raw contents of the mail file.
-     * @return a {@link MailQuery} containing the system prompt, user prompt, and the selected model.
-     * @throws IOException if reading prompt files fails.
-     */
-    private MailQuery parseInputFileContent(String inputFileContent) throws IOException {
-        MailQuery mailQuery = new MailQuery();
-
-        // Find the newline that separates "TOCOMPUTE: ..." from the rest
-        int firstNewLineIndex = inputFileContent.indexOf('\n');
-        if (firstNewLineIndex == -1) {
-            throw new IllegalArgumentException("Input file is only one line long. Content: " + inputFileContent);
-        } else {
-            // The user prompt is everything after the first line
-            mailQuery.userPrompt = inputFileContent.substring(firstNewLineIndex + 1);
-        }
-
-        // The first line will look like "TOCOMPUTE: model=... prompt=... etc."
-        String firstLine = inputFileContent.substring(0, firstNewLineIndex);
-
-        // Parse out the key/value pairs
-        Map<String, String> settings = parseSettings(firstLine);
-
-        // Look up system prompt from the "prompt" alias
-        String promptAlias = settings.getOrDefault("prompt", "default");
-        mailQuery.systemPrompt = configuration.getPromptByAlias(promptAlias);
-
-        // Resolve model from the "model" alias
-        String modelAlias = settings.getOrDefault("model", "default");
-        Optional<Model> modelOptional = modelLibrary.findModelByAlias(modelAlias);
-        if (!modelOptional.isPresent()) {
-            throw new IllegalArgumentException("Model with alias '" + modelAlias + "' not found.");
-        }
-        mailQuery.model = modelOptional.get();
-
-        return mailQuery;
-    }
-
-    /**
-     * 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
-     * or modification), then processes those files if necessary.
-     *
-     * @param key the watch key containing the events.
-     * @throws IOException          if file reading/writing fails.
-     * @throws InterruptedException if the AI process is interrupted.
-     */
-    private void processDetectedFilesystemEvents(WatchKey key) throws IOException, InterruptedException {
-        for (WatchEvent<?> event : key.pollEvents()) {
-            WatchEvent.Kind<?> kind = event.kind();
-
-            // Skip OVERFLOW event
-            if (kind == StandardWatchEventKinds.OVERFLOW) {
-                continue;
-            }
-
-            // The filename for the event
-            Path filename = ((WatchEvent<Path>) event).context();
-            System.out.println("Event: " + kind + " for file: " + filename);
-
-            // Process the file
-            if (kind == ENTRY_CREATE || kind == ENTRY_MODIFY) {
-                File file = mailDir.toPath().resolve(filename).toFile();
-                processMailIfNeeded(file);
-            }
-        }
-    }
-
-    /**
-     * Registers the mail directory with a WatchService for ENTRY_CREATE
-     * and ENTRY_MODIFY events.
-     *
-     * @throws IOException if registration fails.
-     */
-    private void initializeFileWatcher() throws IOException {
-        this.directoryWatcher = FileSystems.getDefault().newWatchService();
-        Paths.get(mailDir.getAbsolutePath()).register(directoryWatcher, ENTRY_CREATE, ENTRY_MODIFY);
-    }
-}
diff --git a/src/main/java/eu/svjatoslav/alyverkko_cli/commands/MailQuery.java b/src/main/java/eu/svjatoslav/alyverkko_cli/commands/MailQuery.java
deleted file mode 100644 (file)
index f1a5627..0000000
+++ /dev/null
@@ -1,41 +0,0 @@
-package eu.svjatoslav.alyverkko_cli.commands;
-
-import eu.svjatoslav.alyverkko_cli.model.Model;
-
-/**
- * Represents the data needed to perform a single mail-based AI query,
- * containing prompts and the specific AI model to use.
- */
-public class MailQuery {
-
-    /**
-     * 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 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;
-
-    /**
-     * Returns a string containing a summary of the {@link MailQuery} 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 +
-                '}';
-    }
-}
index 3a7bd0f..cfe7a2e 100644 (file)
@@ -3,6 +3,8 @@ package eu.svjatoslav.alyverkko_cli.commands;
 import eu.svjatoslav.alyverkko_cli.AiTask;
 import eu.svjatoslav.alyverkko_cli.Command;
 import eu.svjatoslav.alyverkko_cli.Main;
+import eu.svjatoslav.commons.cli_helper.parameter_parser.Parser;
+import eu.svjatoslav.commons.cli_helper.parameter_parser.parameter.FileOption;
 
 import java.io.File;
 import java.io.IOException;
@@ -25,6 +27,15 @@ public class SelftestCommand implements Command {
         return "selftest";
     }
 
+    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();
+
     /**
      * Executes the self-test, checking if various directories, files, and
      * configurations exist and are valid.
@@ -37,7 +48,18 @@ public class SelftestCommand implements Command {
         // Perform selftest checks here
         System.out.println("Starting selftest...");
 
-        configuration = loadConfiguration();
+        if (!parser.parse(cliArguments)) {
+            System.out.println("Failed to parse commandline arguments");
+            parser.showHelp();
+            return;
+        }
+
+        configuration = loadConfiguration(configFileOption.isPresent() ? configFileOption.getValue() : null);
+        if (configuration == null) {
+            System.out.println("Failed to load configuration file");
+            return;
+        }
+
         boolean didAllChecksPass = true;
 
         // Check if the configuration is loaded
diff --git a/src/main/java/eu/svjatoslav/alyverkko_cli/commands/mail_correspondant/MailCorrespondentCommand.java b/src/main/java/eu/svjatoslav/alyverkko_cli/commands/mail_correspondant/MailCorrespondentCommand.java
new file mode 100644 (file)
index 0000000..7bb24ee
--- /dev/null
@@ -0,0 +1,344 @@
+package eu.svjatoslav.alyverkko_cli.commands.mail_correspondant;
+
+import eu.svjatoslav.alyverkko_cli.*;
+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.HashMap;
+import java.util.Map;
+import java.util.Optional;
+
+import static eu.svjatoslav.alyverkko_cli.Main.configuration;
+import static eu.svjatoslav.alyverkko_cli.configuration.Configuration.loadConfiguration;
+import static eu.svjatoslav.commons.file.IOHelper.getFileContentsAsString;
+import static eu.svjatoslav.commons.file.IOHelper.saveToFile;
+import static java.nio.file.StandardWatchEventKinds.ENTRY_CREATE;
+import static java.nio.file.StandardWatchEventKinds.ENTRY_MODIFY;
+
+/**
+ * The MailCorrespondentCommand continuously monitors a specified mail
+ * directory for new or modified text files, checks if they have a
+ * "TOCOMPUTE:" marker, and if so, processes them with an AI model.
+ * Once processed, results are appended to the same file.
+ *
+ * Usage:
+ * <pre>
+ *   alyverkko-cli mail
+ * </pre>
+ */
+public class MailCorrespondentCommand 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 mailDir;
+
+    /**
+     * @return the name of this command, i.e., "mail".
+     */
+    @Override
+    public String getName() {
+        return "mail";
+    }
+
+    /**
+     * Executes the "mail" command, loading configuration, starting a
+     * WatchService on the mail directory, and running an infinite loop
+     * that processes newly discovered tasks.
+     *
+     * @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 execute(String[] cliArguments) throws IOException, InterruptedException {
+        if (!parser.parse(cliArguments)) {
+            System.out.println("Failed to parse commandline arguments");
+            parser.showHelp();
+            return;
+        }
+
+        configuration = loadConfiguration(configFileOption.isPresent() ? configFileOption.getValue() : null);
+        if (configuration == null) {
+            System.out.println("Failed to load configuration file");
+            return;
+        }
+
+        modelLibrary = new ModelLibrary(configuration.getModelsDirectory(), configuration.getModels());
+        mailDir = configuration.getMailDirectory();
+
+        // Set up directory watch service
+        initializeFileWatcher();
+
+        // Process any existing files that might already be in the directory
+        initialMailScanAndReply();
+
+        System.out.println("Mail correspondent running. Press CTRL+c to terminate.");
+
+        // Main loop: watch for file events
+        while (true) {
+            WatchKey key;
+            try {
+                key = directoryWatcher.take();
+            } catch (InterruptedException e) {
+                System.out.println("Interrupted while waiting for file system events. Exiting.");
+                break;
+            }
+
+            System.out.println("Detected filesystem event.");
+
+            // Sleep briefly to allow the file to be fully written
+            Thread.sleep(1000);
+
+            processDetectedFilesystemEvents(key);
+
+            if (!key.reset()) {
+                break;
+            }
+        }
+
+        directoryWatcher.close();
+    }
+
+    /**
+     * Performs an initial scan of existing files in the mail directory,
+     * processing those that need AI inference (i.e., that start with "TOCOMPUTE:").
+     *
+     * @throws IOException          if reading files fails.
+     * @throws InterruptedException if the thread is interrupted.
+     */
+    private void initialMailScanAndReply() throws IOException, InterruptedException {
+        File[] files = mailDir.listFiles();
+        if (files == null) return;
+
+        for (File file : files) {
+            processMailIfNeeded(file);
+        }
+    }
+
+    /**
+     * 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;
+        }
+
+        // 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 {
+        try (BufferedReader reader = new BufferedReader(new FileReader(file))) {
+            String firstLine = reader.readLine();
+            return firstLine != null && firstLine.startsWith("TOCOMPUTE:");
+        }
+    }
+
+    /**
+     * Processes a file if it has the "TOCOMPUTE:" marker, running an AI
+     * query and appending the result to the file. Otherwise logs that
+     * it's being ignored.
+     *
+     * @param file the file to possibly process.
+     * @throws IOException          if reading/writing the file fails.
+     * @throws InterruptedException if the AI query is interrupted.
+     */
+    private void processMailIfNeeded(File file) throws IOException, InterruptedException {
+        if (!isMailProcessingNeeded(file)) {
+            System.out.println("Ignoring file: " + file.getName() + " (does not need processing for now)");
+            return;
+        }
+
+        System.out.println("\nReplying to mail: " + file.getName());
+
+        // Read the mail content
+        String inputFileContent = getFileContentsAsString(file);
+
+        // Parse the relevant data into a MailQuery object
+        MailQuery mailQuery = parseInputFileContent(inputFileContent);
+
+        // Create an AiTask and run the query
+        AiTask aiTask = new AiTask(mailQuery);
+        String aiGeneratedResponse = aiTask.runAiQuery();
+
+        // Build new content
+        StringBuilder resultFileContent = new StringBuilder();
+
+        // Ensure the user prompt block is labeled if it isn't already
+        if (!mailQuery.userPrompt.startsWith("* USER:\n")) {
+            resultFileContent.append("* USER:\n");
+        }
+        resultFileContent.append(mailQuery.userPrompt).append("\n");
+
+        // Append the AI response block
+        resultFileContent
+                .append("* ASSISTANT:\n")
+                .append(aiGeneratedResponse)
+                .append("\n");
+
+        // Write the combined result back to the same file
+        saveToFile(file, resultFileContent.toString());
+    }
+
+    /**
+     * Converts the raw file content (including the line beginning with "TOCOMPUTE:")
+     * into a {@link MailQuery} object that the AI can process.
+     *
+     * @param inputFileContent the raw contents of the mail file.
+     * @return a {@link MailQuery} containing the system prompt, user prompt, and the selected model.
+     * @throws IOException if reading prompt files fails.
+     */
+    private MailQuery parseInputFileContent(String inputFileContent) throws IOException {
+        MailQuery mailQuery = new MailQuery();
+
+        // Find the newline that separates "TOCOMPUTE: ..." from the rest
+        int firstNewLineIndex = inputFileContent.indexOf('\n');
+        if (firstNewLineIndex == -1) {
+            throw new IllegalArgumentException("Input file is only one line long. Content: " + inputFileContent);
+        } else {
+            // The user prompt is everything after the first line
+            mailQuery.userPrompt = inputFileContent.substring(firstNewLineIndex + 1);
+        }
+
+        // The first line will look like "TOCOMPUTE: model=... prompt=... etc."
+        String firstLine = inputFileContent.substring(0, firstNewLineIndex);
+
+        // Parse out the key/value pairs
+        Map<String, String> settings = parseSettings(firstLine);
+
+        // Look up system prompt from the "prompt" alias
+        String promptAlias = settings.getOrDefault("prompt", "default");
+        mailQuery.systemPrompt = configuration.getPromptByAlias(promptAlias);
+
+        // Resolve model from the "model" alias
+        String modelAlias = settings.getOrDefault("model", "default");
+        Optional<Model> modelOptional = modelLibrary.findModelByAlias(modelAlias);
+        if (!modelOptional.isPresent()) {
+            throw new IllegalArgumentException("Model with alias '" + modelAlias + "' not found.");
+        }
+        mailQuery.model = modelOptional.get();
+
+        return mailQuery;
+    }
+
+    /**
+     * 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
+     * or modification), then processes those files if necessary.
+     *
+     * @param key the watch key containing the events.
+     * @throws IOException          if file reading/writing fails.
+     * @throws InterruptedException if the AI process is interrupted.
+     */
+    private void processDetectedFilesystemEvents(WatchKey key) throws IOException, InterruptedException {
+        for (WatchEvent<?> event : key.pollEvents()) {
+            WatchEvent.Kind<?> kind = event.kind();
+
+            // Skip OVERFLOW event
+            if (kind == StandardWatchEventKinds.OVERFLOW) {
+                continue;
+            }
+
+            // The filename for the event
+            Path filename = ((WatchEvent<Path>) event).context();
+            System.out.println("Event: " + kind + " for file: " + filename);
+
+            // Process the file
+            if (kind == ENTRY_CREATE || kind == ENTRY_MODIFY) {
+                File file = mailDir.toPath().resolve(filename).toFile();
+                processMailIfNeeded(file);
+            }
+        }
+    }
+
+    /**
+     * Registers the mail directory with a WatchService for ENTRY_CREATE
+     * and ENTRY_MODIFY events.
+     *
+     * @throws IOException if registration fails.
+     */
+    private void initializeFileWatcher() throws IOException {
+        this.directoryWatcher = FileSystems.getDefault().newWatchService();
+        Paths.get(mailDir.getAbsolutePath()).register(directoryWatcher, ENTRY_CREATE, ENTRY_MODIFY);
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/alyverkko_cli/commands/mail_correspondant/MailQuery.java b/src/main/java/eu/svjatoslav/alyverkko_cli/commands/mail_correspondant/MailQuery.java
new file mode 100644 (file)
index 0000000..a33584f
--- /dev/null
@@ -0,0 +1,41 @@
+package eu.svjatoslav.alyverkko_cli.commands.mail_correspondant;
+
+import eu.svjatoslav.alyverkko_cli.model.Model;
+
+/**
+ * Represents the data needed to perform a single mail-based AI query,
+ * containing prompts and the specific AI model to use.
+ */
+public class MailQuery {
+
+    /**
+     * 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 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;
+
+    /**
+     * Returns a string containing a summary of the {@link MailQuery} 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 +
+                '}';
+    }
+}