Add step 630 chat session persistence

This commit is contained in:
Bill
2026-02-17 21:21:20 -07:00
parent 695a4bc088
commit c78d1d82ac
6 changed files with 434 additions and 0 deletions

View File

@@ -5,6 +5,7 @@
#include "AgentMutationPreview.h"
#include "AgentMutationApproval.h"
#include <cstddef>
#include <cctype>
#include <string>
#include <vector>
@@ -28,6 +29,9 @@ struct AgentChatState {
std::vector<MutationApprovalRecord> mutationApprovals;
std::string systemContext;
std::string draftInput;
std::string loadedProjectFile;
std::size_t persistedMessageCount = 0;
bool sessionLoaded = false;
bool autoScroll = true;
bool open = false;
};

View File

@@ -0,0 +1,167 @@
#pragma once
// Step 630: Chat session persistence
#include "AgentChatPanelModel.h"
#include <filesystem>
#include <fstream>
#include <string>
#include <vector>
#include <nlohmann/json.hpp>
using json = nlohmann::json;
namespace fs = std::filesystem;
struct AgentChatSavedSession {
std::string timestamp;
AgentChatState chat;
};
class AgentChatSessionPersistence {
public:
static std::string sessionStorePath(const std::string& workspaceRoot) {
fs::path root = workspaceRoot.empty() ? fs::path(".") : fs::path(workspaceRoot);
return (root / ".whetstone.chat_sessions.json").string();
}
static bool saveSession(const std::string& workspaceRoot,
const std::string& projectFile,
const std::string& timestamp,
const AgentChatState& chat,
std::string* error) {
if (!error) return false;
error->clear();
if (projectFile.empty()) return fail(error, "project_file_missing");
if (timestamp.empty()) return fail(error, "timestamp_missing");
json store = readStore(workspaceRoot, error);
if (!error->empty()) return false;
if (!store.contains("projects")) store["projects"] = json::object();
if (!store["projects"].contains(projectFile)) store["projects"][projectFile] = json::array();
store["projects"][projectFile].push_back({
{"timestamp", timestamp},
{"chat", chatToJson(chat)}
});
return writeStore(workspaceRoot, store, error);
}
static bool loadLatestSession(const std::string& workspaceRoot,
const std::string& projectFile,
AgentChatSavedSession* out,
std::string* error) {
if (!out || !error) return false;
error->clear();
out->timestamp.clear();
out->chat = AgentChatState{};
if (projectFile.empty()) return fail(error, "project_file_missing");
json store = readStore(workspaceRoot, error);
if (!error->empty()) return false;
if (!store.contains("projects") ||
!store["projects"].contains(projectFile) ||
!store["projects"][projectFile].is_array() ||
store["projects"][projectFile].empty()) {
return fail(error, "session_not_found");
}
const auto& latest = store["projects"][projectFile].back();
out->timestamp = latest.value("timestamp", "");
if (!latest.contains("chat") || !latest["chat"].is_object()) {
return fail(error, "chat_payload_invalid");
}
out->chat = jsonToChat(latest["chat"]);
return true;
}
static std::vector<std::string> listSessionTimestamps(const std::string& workspaceRoot,
const std::string& projectFile,
std::string* error) {
std::vector<std::string> out;
if (!error) return out;
error->clear();
if (projectFile.empty()) {
*error = "project_file_missing";
return out;
}
json store = readStore(workspaceRoot, error);
if (!error->empty()) return out;
if (!store.contains("projects") || !store["projects"].contains(projectFile)) return out;
for (const auto& session : store["projects"][projectFile]) {
out.push_back(session.value("timestamp", ""));
}
return out;
}
private:
static bool fail(std::string* error, const char* code) {
*error = code;
return false;
}
static json chatToJson(const AgentChatState& chat) {
json messages = json::array();
for (const auto& message : chat.messages) {
messages.push_back({
{"role", AgentChatPanelModel::roleLabel(message.role)},
{"content", message.content},
{"timestamp", message.timestamp}
});
}
return {
{"systemContext", chat.systemContext},
{"messages", messages}
};
}
static AgentChatState jsonToChat(const json& value) {
AgentChatState out;
out.systemContext = value.value("systemContext", "");
if (!value.contains("messages") || !value["messages"].is_array()) return out;
for (const auto& message : value["messages"]) {
AgentChatRole role = AgentChatRole::Tool;
const std::string roleText = message.value("role", "tool");
if (roleText == "user") role = AgentChatRole::User;
else if (roleText == "assistant") role = AgentChatRole::Assistant;
out.messages.push_back({
role,
message.value("content", ""),
message.value("timestamp", "")
});
}
return out;
}
static json readStore(const std::string& workspaceRoot, std::string* error) {
const std::string path = sessionStorePath(workspaceRoot);
if (!fs::exists(path)) return json::object();
std::ifstream in(path);
if (!in.is_open()) {
*error = "session_store_open_failed";
return json::object();
}
try {
json parsed = json::parse(in);
if (!parsed.is_object()) {
*error = "session_store_invalid";
return json::object();
}
return parsed;
} catch (...) {
*error = "session_store_parse_failed";
return json::object();
}
}
static bool writeStore(const std::string& workspaceRoot,
const json& store,
std::string* error) {
fs::path path = sessionStorePath(workspaceRoot);
if (path.has_parent_path()) fs::create_directories(path.parent_path());
std::ofstream out(path);
if (!out.is_open()) return fail(error, "session_store_write_failed");
out << store.dump(2);
return true;
}
};

View File

@@ -2,6 +2,7 @@
#include "../AgentChatPanelModel.h"
#include "../AgentChatContextInjector.h"
#include "../AgentChatSessionPersistence.h"
#include "../EditorState.h"
#include "../EditorUtils.h"
@@ -20,6 +21,25 @@ static void renderAgentChatPanel(EditorState& state) {
}
AgentChatState& chat = state.agent.chat;
const std::string projectFile = state.activeBuffer ? state.activeBuffer->path : "";
if (!projectFile.empty() &&
(!chat.sessionLoaded || chat.loadedProjectFile != projectFile)) {
AgentChatSavedSession loaded;
std::string error;
if (AgentChatSessionPersistence::loadLatestSession(state.workspaceRoot,
projectFile,
&loaded,
&error)) {
chat = loaded.chat;
chat.loadedProjectFile = projectFile;
chat.sessionLoaded = true;
chat.persistedMessageCount = chat.messages.size();
} else {
chat.loadedProjectFile = projectFile;
chat.sessionLoaded = true;
}
}
if (chat.systemContext.empty()) {
AgentChatRuntimeSnapshot snapshot;
if (state.activeBuffer) {
@@ -122,5 +142,18 @@ static void renderAgentChatPanel(EditorState& state) {
AgentChatPanelModel::sendUserMessage(&chat, "now");
}
if (!projectFile.empty() &&
chat.sessionLoaded &&
chat.messages.size() != chat.persistedMessageCount) {
std::string error;
if (AgentChatSessionPersistence::saveSession(state.workspaceRoot,
projectFile,
"autosave",
chat,
&error)) {
chat.persistedMessageCount = chat.messages.size();
}
}
ImGui::End();
}