diff --git a/CMakeLists.txt b/CMakeLists.txt index bb879fc..3e0e6a5 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -146,6 +146,7 @@ add_qtc_plugin(QodeAssist tools/ReadFileTool.hpp tools/ReadFileTool.cpp tools/FileSearchUtils.hpp tools/FileSearchUtils.cpp tools/TodoTool.hpp tools/TodoTool.cpp + tools/ReadOriginalHistoryTool.hpp tools/ReadOriginalHistoryTool.cpp mcp/McpServerManager.hpp mcp/McpServerManager.cpp mcp/McpServerConnection.hpp mcp/McpServerConnection.cpp mcp/McpClientsManager.hpp mcp/McpClientsManager.cpp diff --git a/ChatView/ChatCompressor.cpp b/ChatView/ChatCompressor.cpp index d53678a..e7a5344 100644 --- a/ChatView/ChatCompressor.cpp +++ b/ChatView/ChatCompressor.cpp @@ -228,6 +228,8 @@ bool ChatCompressor::createCompressedChatFile( summaryMessage["images"] = QJsonArray(); root["messages"] = QJsonArray{summaryMessage}; + root["compressedFrom"] = sourcePath; + root["compressedAt"] = QDateTime::currentDateTime().toString(Qt::ISODate); if (QFile::exists(destPath)) QFile::remove(destPath); diff --git a/ChatView/ClientInterface.cpp b/ChatView/ClientInterface.cpp index eebbc4e..8030776 100644 --- a/ChatView/ClientInterface.cpp +++ b/ChatView/ClientInterface.cpp @@ -28,6 +28,7 @@ #include +#include "tools/ReadOriginalHistoryTool.hpp" #include "tools/TodoTool.hpp" #include "ChatAssistantSettings.hpp" @@ -305,6 +306,10 @@ void ClientInterface::sendMessage( provider->toolsManager()->tool("todo_tool"))) { todoTool->setCurrentSessionId(m_chatFilePath); } + if (auto *historyTool = qobject_cast( + provider->toolsManager()->tool("read_original_history"))) { + historyTool->setCurrentSessionId(m_chatFilePath); + } } } diff --git a/settings/SettingsConstants.hpp b/settings/SettingsConstants.hpp index 40a4e8e..130c6d1 100644 --- a/settings/SettingsConstants.hpp +++ b/settings/SettingsConstants.hpp @@ -100,6 +100,8 @@ const char CA_ENABLE_EDIT_FILE_TOOL[] = "QodeAssist.caEnableEditFileToolV2"; const char CA_ENABLE_BUILD_PROJECT_TOOL[] = "QodeAssist.caEnableBuildProjectToolV2"; const char CA_ENABLE_TERMINAL_COMMAND_TOOL[] = "QodeAssist.caEnableTerminalCommandToolV2"; const char CA_ENABLE_TODO_TOOL[] = "QodeAssist.caEnableTodoToolV2"; +const char CA_ENABLE_READ_ORIGINAL_HISTORY_TOOL[] + = "QodeAssist.caEnableReadOriginalHistoryTool"; const char CA_ALLOWED_TERMINAL_COMMANDS[] = "QodeAssist.caAllowedTerminalCommands"; const char CA_ALLOWED_TERMINAL_COMMANDS_LINUX[] = "QodeAssist.caAllowedTerminalCommandsLinux"; const char CA_ALLOWED_TERMINAL_COMMANDS_MACOS[] = "QodeAssist.caAllowedTerminalCommandsMacOS"; diff --git a/settings/ToolsSettings.cpp b/settings/ToolsSettings.cpp index b61e069..c05bbf7 100644 --- a/settings/ToolsSettings.cpp +++ b/settings/ToolsSettings.cpp @@ -111,6 +111,14 @@ ToolsSettings::ToolsSettings() Tr::tr("Lets the AI maintain a session-scoped todo list for multi-step workflows.")); enableTodoTool.setDefaultValue(true); + enableReadOriginalHistoryTool.setSettingsKey(Constants::CA_ENABLE_READ_ORIGINAL_HISTORY_TOOL); + enableReadOriginalHistoryTool.setLabelText(Tr::tr("Read Original History (Pre-Compression)")); + enableReadOriginalHistoryTool.setToolTip( + Tr::tr("Lets the AI read the original, full chat history from before the conversation " + "was compressed into a summary. Useful when a detail is missing from the " + "summary currently in context. Has no effect if the chat was never compressed.")); + enableReadOriginalHistoryTool.setDefaultValue(true); + allowedTerminalCommandsLinux.setSettingsKey(Constants::CA_ALLOWED_TERMINAL_COMMANDS_LINUX); allowedTerminalCommandsLinux.setLabelText(Tr::tr("Allowed Commands (Linux)")); allowedTerminalCommandsLinux.setToolTip( @@ -177,7 +185,8 @@ ToolsSettings::ToolsSettings() enableBuildProjectTool, enableGetIssuesListTool, enableTerminalCommandTool, - enableTodoTool}}, + enableTodoTool, + enableReadOriginalHistoryTool}}, Space{8}, Group{ title(Tr::tr("Tool Settings")), @@ -227,6 +236,7 @@ void ToolsSettings::resetSettingsToDefaults() resetAspect(enableGetIssuesListTool); resetAspect(enableTerminalCommandTool); resetAspect(enableTodoTool); + resetAspect(enableReadOriginalHistoryTool); resetAspect(allowedTerminalCommandsLinux); resetAspect(allowedTerminalCommandsMacOS); resetAspect(allowedTerminalCommandsWindows); diff --git a/settings/ToolsSettings.hpp b/settings/ToolsSettings.hpp index 7aaa33b..5ca0124 100644 --- a/settings/ToolsSettings.hpp +++ b/settings/ToolsSettings.hpp @@ -30,6 +30,7 @@ public: Utils::BoolAspect enableGetIssuesListTool{this}; Utils::BoolAspect enableTerminalCommandTool{this}; Utils::BoolAspect enableTodoTool{this}; + Utils::BoolAspect enableReadOriginalHistoryTool{this}; Utils::StringAspect allowedTerminalCommandsLinux{this}; Utils::StringAspect allowedTerminalCommandsMacOS{this}; diff --git a/tools/ReadOriginalHistoryTool.cpp b/tools/ReadOriginalHistoryTool.cpp new file mode 100644 index 0000000..6b808bf --- /dev/null +++ b/tools/ReadOriginalHistoryTool.cpp @@ -0,0 +1,196 @@ +// Copyright (C) 2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ReadOriginalHistoryTool.hpp" + +#include + +#include +#include +#include +#include +#include + +namespace QodeAssist::Tools { + +namespace { + +QString roleName(int role) +{ + switch (role) { + case 0: + return QStringLiteral("system"); + case 1: + return QStringLiteral("user"); + case 2: + return QStringLiteral("assistant"); + case 3: + return QStringLiteral("tool"); + case 4: + return QStringLiteral("file_edit"); + case 5: + return QStringLiteral("thinking"); + default: + return QStringLiteral("unknown"); + } +} + +QJsonObject readJsonObject(const QString &path) +{ + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) + return {}; + + const QJsonDocument doc = QJsonDocument::fromJson(file.readAll()); + return doc.isObject() ? doc.object() : QJsonObject{}; +} + +QString resolveRootHistoryPath(const QString &sessionPath) +{ + QString current = sessionPath; + QString rootPath; + + for (int depth = 0; depth < 32; ++depth) { + const QJsonObject obj = readJsonObject(current); + const QString parent = obj.value("compressedFrom").toString(); + if (parent.isEmpty() || parent == current) + break; + if (!QFile::exists(parent)) + break; + rootPath = parent; + current = parent; + } + + return rootPath; +} + +} // namespace + +ReadOriginalHistoryTool::ReadOriginalHistoryTool(QObject *parent) + : BaseTool(parent) +{} + +QString ReadOriginalHistoryTool::id() const +{ + return "read_original_history"; +} + +QString ReadOriginalHistoryTool::displayName() const +{ + return "Reading pre-compression history"; +} + +QString ReadOriginalHistoryTool::description() const +{ + return "Read the original, full chat history from before this conversation was " + "compressed into a summary. Use this only when the summary in context is " + "missing a detail you need (an exact code snippet, file path, decision, or " + "wording). The result can be large, so prefer the 'query' parameter to search " + "and 'offset'/'limit' to page through messages. Returns nothing useful if the " + "conversation was never compressed."; +} + +QJsonObject ReadOriginalHistoryTool::parametersSchema() const +{ + QJsonObject properties; + + properties["query"] = QJsonObject{ + {"type", "string"}, + {"description", + "Optional case-insensitive substring. When set, only messages whose content " + "contains it are returned."}}; + + properties["role"] = QJsonObject{ + {"type", "string"}, + {"description", + "Optional role filter: 'user', 'assistant', 'system' or 'tool'."}}; + + properties["offset"] = QJsonObject{ + {"type", "integer"}, + {"description", "Index of the first matching message to return (default 0)."}}; + + properties["limit"] = QJsonObject{ + {"type", "integer"}, + {"description", "Maximum number of messages to return (default 20)."}}; + + QJsonObject definition; + definition["type"] = "object"; + definition["properties"] = properties; + definition["required"] = QJsonArray{}; + + return definition; +} + +QFuture ReadOriginalHistoryTool::executeAsync(const QJsonObject &input) +{ + QString sessionPath; + { + QMutexLocker locker(&m_mutex); + sessionPath = m_currentSessionId; + } + + return QtConcurrent::run([input, sessionPath]() -> LLMQore::ToolResult { + if (sessionPath.isEmpty()) { + throw LLMQore::ToolRuntimeError( + "No active chat session, cannot locate pre-compression history."); + } + + const QString rootPath = resolveRootHistoryPath(sessionPath); + if (rootPath.isEmpty()) { + return LLMQore::ToolResult::text( + "This conversation was never compressed; there is no separate " + "pre-compression history. The messages already in context are the full " + "history."); + } + + const QJsonObject root = readJsonObject(rootPath); + const QJsonArray messages = root.value("messages").toArray(); + + const QString query = input.value("query").toString().trimmed(); + const QString roleFilter = input.value("role").toString().trimmed().toLower(); + const int offset = qMax(0, input.value("offset").toInt(0)); + const int limit = qBound(1, input.value("limit").toInt(20), 200); + + QStringList matched; + int matchCount = 0; + for (int i = 0; i < messages.size(); ++i) { + const QJsonObject msg = messages.at(i).toObject(); + const QString role = roleName(msg.value("role").toInt()); + const QString content = msg.value("content").toString(); + + if (!roleFilter.isEmpty() && role != roleFilter) + continue; + if (!query.isEmpty() && !content.contains(query, Qt::CaseInsensitive)) + continue; + + ++matchCount; + if (matchCount <= offset || matched.size() >= limit) + continue; + + matched.append(QString("[#%1 %2]\n%3").arg(i).arg(role, content)); + } + + const int shown = matched.size(); + QString header = QString("Pre-compression history (%1): %2 matching message(s)") + .arg(rootPath) + .arg(matchCount); + if (shown < matchCount || offset > 0) { + header += QString(", showing %1-%2") + .arg(offset + 1) + .arg(offset + shown); + } + + if (shown == 0) + return LLMQore::ToolResult::text(header + "\n\nNo messages to display."); + + return LLMQore::ToolResult::text(header + "\n\n" + matched.join("\n\n---\n\n")); + }); +} + +void ReadOriginalHistoryTool::setCurrentSessionId(const QString &sessionId) +{ + QMutexLocker locker(&m_mutex); + m_currentSessionId = sessionId; +} + +} // namespace QodeAssist::Tools diff --git a/tools/ReadOriginalHistoryTool.hpp b/tools/ReadOriginalHistoryTool.hpp new file mode 100644 index 0000000..2510d03 --- /dev/null +++ b/tools/ReadOriginalHistoryTool.hpp @@ -0,0 +1,34 @@ +// Copyright (C) 2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include + +#include +#include + +namespace QodeAssist::Tools { + +class ReadOriginalHistoryTool : public ::LLMQore::BaseTool +{ + Q_OBJECT + +public: + explicit ReadOriginalHistoryTool(QObject *parent = nullptr); + + QString id() const override; + QString displayName() const override; + QString description() const override; + QJsonObject parametersSchema() const override; + + QFuture executeAsync(const QJsonObject &input = QJsonObject()) override; + + void setCurrentSessionId(const QString &sessionId); + +private: + mutable QMutex m_mutex; + QString m_currentSessionId; +}; + +} // namespace QodeAssist::Tools diff --git a/tools/ToolsRegistration.cpp b/tools/ToolsRegistration.cpp index 04c8329..4724c97 100644 --- a/tools/ToolsRegistration.cpp +++ b/tools/ToolsRegistration.cpp @@ -17,6 +17,7 @@ #include "ListProjectFilesTool.hpp" #include "ProjectSearchTool.hpp" #include "ReadFileTool.hpp" +#include "ReadOriginalHistoryTool.hpp" #include "TodoTool.hpp" namespace QodeAssist::Tools { @@ -61,6 +62,8 @@ void registerQodeAssistTools(::LLMQore::ToolsManager *manager) wireTool( manager, s.enableTerminalCommandTool, "execute_terminal_command"); wireTool(manager, s.enableTodoTool, "todo_tool"); + wireTool( + manager, s.enableReadOriginalHistoryTool, "read_original_history"); } } // namespace QodeAssist::Tools