diff --git a/PROGRESS.md b/PROGRESS.md index d821a8b..3c81180 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -749,3 +749,4 @@ vcpkg's imgui 1.91.9 removed the `sdl2-binding` feature (only `sdl3-binding` exi | 2026-02-10 | Codex | Step 227: Open-source model tool definitions (ReAct-style prompt, XML tool call format, simplified schemas, adapter rules). 1/1 tests pass. | | 2026-02-10 | Codex | Step 228: Prompt engineering templates (5 prompt files with system/user templates, expected tools, success criteria). 1/1 tests pass. | | 2026-02-10 | Codex | Step 229: Tool definition tests (schema alignment, prompt coverage, examples validation, prompt variable checks). 1/1 tests pass. | +| 2026-02-10 | Codex | Step 230: Enhanced session recorder (metadata, event capture, ms timestamps, UI toggle, auto-record setting). 1/1 tests pass. | diff --git a/editor/CMakeLists.txt b/editor/CMakeLists.txt index 9be8795..20056fe 100644 --- a/editor/CMakeLists.txt +++ b/editor/CMakeLists.txt @@ -1172,6 +1172,9 @@ target_link_libraries(step228_test PRIVATE nlohmann_json::nlohmann_json) add_executable(step229_test tests/step229_test.cpp) target_include_directories(step229_test PRIVATE src) target_link_libraries(step229_test PRIVATE nlohmann_json::nlohmann_json) +add_executable(step230_test tests/step230_test.cpp) +target_include_directories(step230_test PRIVATE src) +target_link_libraries(step230_test PRIVATE nlohmann_json::nlohmann_json) add_executable(step213_test tests/step213_test.cpp) target_include_directories(step213_test PRIVATE src) target_link_libraries(step213_test PRIVATE nlohmann_json::nlohmann_json) diff --git a/editor/src/EditorState.h b/editor/src/EditorState.h index ca30f85..f10d23a 100644 --- a/editor/src/EditorState.h +++ b/editor/src/EditorState.h @@ -733,6 +733,91 @@ struct EditorState { uiAnimations.recordTabSwitch(path, ImGui::GetTime()); } + static std::string osLabel() { +#ifdef _WIN32 + return "windows"; +#elif __APPLE__ + return "macos"; +#else + return "linux"; +#endif + } + + std::string detectProjectType() const { + std::filesystem::path root(workspaceRoot); + std::error_code ec; + auto exists = [&](const std::string& name) { + return std::filesystem::exists(root / name, ec); + }; + if (exists("CMakeLists.txt")) return "cmake"; + if (exists("package.json")) return "node"; + if (exists("pyproject.toml") || exists("requirements.txt")) return "python"; + if (exists("Cargo.toml")) return "rust"; + if (exists("go.mod")) return "go"; + if (exists("pom.xml") || exists("build.gradle")) return "java"; + return "unknown"; + } + + json buildSessionMetadata(bool autoRecording) const { + json meta; + meta["editorVersion"] = welcome.getVersion(); + meta["os"] = osLabel(); + meta["language"] = active() ? active()->language : std::string("unknown"); + meta["projectType"] = detectProjectType(); + meta["autoRecording"] = autoRecording; + return meta; + } + + void startSessionRecording(const std::string& name, bool autoRecording) { + WorkflowRecorder::RecordingConfig cfg; + cfg.recordAllSessions = true; + cfg.recordControlMethods = true; + cfg.recordEditorEvents = true; + cfg.autoRecording = autoRecording; + agent.workflowRecorder.startRecording(name, "", cfg, buildSessionMetadata(autoRecording)); + agent.workflowRecorder.recordEvent("session_start", {{"name", name}, {"autoRecording", autoRecording}}); + notify(NotificationLevel::Info, "Session recording started."); + } + + void stopSessionRecording() { + if (!agent.workflowRecorder.isRecording()) return; + agent.workflowRecorder.recordEvent("session_stop", json::object()); + agent.workflowRecorder.stopRecording(); + notify(NotificationLevel::Info, "Session recording stopped."); + } + + void toggleSessionRecording() { + if (agent.workflowRecorder.isRecording()) stopSessionRecording(); + else startSessionRecording("session", false); + } + + static std::string uiEventLabel(UIEventType type) { + switch (type) { + case UIEventType::ASTChanged: return "ast_changed"; + case UIEventType::BufferSwitched: return "buffer_switched"; + case UIEventType::DiagnosticsUpdated: return "diagnostics_updated"; + case UIEventType::ThemeChanged: return "theme_changed"; + case UIEventType::SettingsChanged: return "settings_changed"; + case UIEventType::FileModified: return "file_modified"; + case UIEventType::AgentConnected: return "agent_connected"; + case UIEventType::NotificationPosted: return "notification_posted"; + } + return "unknown"; + } + + void recordUIEvent(const UIEvent& ev) { + json payload = { + {"path", ev.path}, + {"detail", ev.detail}, + {"uiTimestamp", ev.timestamp} + }; + agent.workflowRecorder.recordEvent(uiEventLabel(ev.type), payload); + } + + void recordEditorEvent(const std::string& type, const json& payload) { + agent.workflowRecorder.recordEvent(type, payload); + } + std::filesystem::path configDir() const { const char* home = std::getenv("USERPROFILE"); if (!home) home = std::getenv("HOME"); @@ -1076,10 +1161,13 @@ struct EditorState { loadRecentFiles(); loadFeatureHints(featureHints, workspaceRoot); loadSettingsFromDisk(); + if (settings.getAutoRecordSessions() && !agent.workflowRecorder.isRecording()) { + startSessionRecording("auto-session", true); + } registerCommands(); library.vulnDb.startBackgroundRefresh(); library.semanticTags.load(); - events.subscribe(UIEventType::BufferSwitched, [this](const UIEvent&) { + events.subscribe(UIEventType::BufferSwitched, [this](const UIEvent& ev) { if (!active()) return; library.primitives.setRoot(activeAST()); library.primitives.setLanguage(active()->language); @@ -1089,6 +1177,7 @@ struct EditorState { if (active()->language == "elisp") { emacsState.emacsFunctionIndexDirty = true; } + recordUIEvent(ev); }); events.subscribe(UIEventType::ASTChanged, [this](const UIEvent&) { if (!active()) return; @@ -1098,8 +1187,18 @@ struct EditorState { symbolsLastChange = ImGui::GetTime(); if (lsp) lsp->clearDocumentSymbols(); }); - events.subscribe(UIEventType::SettingsChanged, [this](const UIEvent&) { + events.subscribe(UIEventType::SettingsChanged, [this](const UIEvent& ev) { applySettingsToState(); + recordUIEvent(ev); + }); + events.subscribe(UIEventType::ThemeChanged, [this](const UIEvent& ev) { + recordUIEvent(ev); + }); + events.subscribe(UIEventType::FileModified, [this](const UIEvent& ev) { + recordUIEvent(ev); + }); + events.subscribe(UIEventType::NotificationPosted, [this](const UIEvent& ev) { + recordUIEvent(ev); }); startEmacsDaemonFromSettings(); initAgentServer(); @@ -1209,8 +1308,10 @@ struct EditorState { logAgentEvent("Agent " + s.sessionId + " " + event); if (event == "connected") { agent.roles[s.sessionId] = agent.defaultRole; + recordEditorEvent("agent_connected", {{"sessionId", s.sessionId}, {"agentName", s.agentName}}); } else if (event == "disconnected") { agent.roles.erase(s.sessionId); + recordEditorEvent("agent_disconnected", {{"sessionId", s.sessionId}}); } }); agent.server->setRequestLogCallback([this](const std::string& sid, @@ -1510,7 +1611,8 @@ struct EditorState { if (method == "startWorkflowRecording") { auto params = request.contains("params") ? request["params"] : json::object(); std::string name = params.value("name", "workflow"); - agent.workflowRecorder.startRecording(name, sessionId); + agent.workflowRecorder.startRecording(name, sessionId, WorkflowRecorder::RecordingConfig{}, + buildSessionMetadata(false)); response["result"] = { {"recording", true}, {"name", name} @@ -2740,6 +2842,7 @@ struct EditorState { active()->modified = false; notify(NotificationLevel::Success, "Saved: " + active()->path); + recordEditorEvent("file_saved", {{"path", active()->path}}); if (lsp) lsp->didSave(toFileUri(active()->path), active()->editBuf); } else { notify(NotificationLevel::Error, @@ -2809,6 +2912,12 @@ struct EditorState { largeFilePromptBytes = fileSizeBytes; } notify(NotificationLevel::Success, "Opened: " + path); + recordEditorEvent("file_opened", { + {"path", path}, + {"language", language}, + {"mode", bufferModeToString(effectiveMode)}, + {"sizeBytes", fileSizeBytes} + }); } else { notify(NotificationLevel::Error, "Error opening: " + path); } diff --git a/editor/src/SettingsManager.h b/editor/src/SettingsManager.h index 585072d..3cb8b64 100644 --- a/editor/src/SettingsManager.h +++ b/editor/src/SettingsManager.h @@ -62,6 +62,8 @@ public: void setUseAnnotationShapes(bool value) { useAnnotationShapes_ = value; } bool getBlockVulnerableImports() const { return blockVulnerableImports_; } void setBlockVulnerableImports(bool value) { blockVulnerableImports_ = value; } + bool getAutoRecordSessions() const { return autoRecordSessions_; } + void setAutoRecordSessions(bool value) { autoRecordSessions_ = value; } int getLargeFileWarnMB() const { return largeFileWarnMB_; } void setLargeFileWarnMB(int value) { largeFileWarnMB_ = value; } int getLargeFileTextMB() const { return largeFileTextMB_; } @@ -101,6 +103,7 @@ public: reduceMotion_ = j.value("reduceMotion", reduceMotion_); useAnnotationShapes_ = j.value("useAnnotationShapes", useAnnotationShapes_); blockVulnerableImports_ = j.value("blockVulnerableImports", blockVulnerableImports_); + autoRecordSessions_ = j.value("autoRecordSessions", autoRecordSessions_); largeFileWarnMB_ = j.value("largeFileWarnMB", largeFileWarnMB_); largeFileTextMB_ = j.value("largeFileTextMB", largeFileTextMB_); largeFileDisableHighlightMB_ = j.value("largeFileDisableHighlightMB", largeFileDisableHighlightMB_); @@ -147,6 +150,7 @@ public: j["reduceMotion"] = reduceMotion_; j["useAnnotationShapes"] = useAnnotationShapes_; j["blockVulnerableImports"] = blockVulnerableImports_; + j["autoRecordSessions"] = autoRecordSessions_; j["largeFileWarnMB"] = largeFileWarnMB_; j["largeFileTextMB"] = largeFileTextMB_; j["largeFileDisableHighlightMB"] = largeFileDisableHighlightMB_; @@ -230,6 +234,7 @@ private: bool reduceMotion_ = false; bool useAnnotationShapes_ = true; bool blockVulnerableImports_ = false; + bool autoRecordSessions_ = false; int largeFileWarnMB_ = 1; int largeFileTextMB_ = 5; int largeFileDisableHighlightMB_ = 10; diff --git a/editor/src/WorkflowRecorder.h b/editor/src/WorkflowRecorder.h index 60b19a1..0d661c1 100644 --- a/editor/src/WorkflowRecorder.h +++ b/editor/src/WorkflowRecorder.h @@ -12,19 +12,44 @@ using json = nlohmann::json; class WorkflowRecorder { public: + struct RecordingConfig { + bool recordAllSessions = false; + bool recordControlMethods = false; + bool recordEditorEvents = true; + bool autoRecording = false; + }; + struct WorkflowCall { json request; json response; std::string timestamp; + std::string sessionId; + std::string method; + }; + + struct WorkflowEvent { + std::string type; + json payload; + std::string timestamp; }; void startRecording(const std::string& name, const std::string& sessionId) { + startRecording(name, sessionId, RecordingConfig{}, json::object()); + } + + void startRecording(const std::string& name, + const std::string& sessionId, + const RecordingConfig& config, + const json& metadata) { name_ = name.empty() ? "workflow" : name; sessionId_ = sessionId; calls_.clear(); + events_.clear(); recording_ = true; createdAt_ = currentTimestamp(); stoppedAt_.clear(); + config_ = config; + metadata_ = metadata; } json stopRecording() { @@ -37,36 +62,60 @@ public: bool isReplaying() const { return replaying_; } void setReplaying(bool replaying) { replaying_ = replaying; } + bool isAutoRecording() const { return config_.autoRecording; } void record(const std::string& sessionId, const json& request, const json& response) { if (!recording_ || replaying_) return; - if (!sessionId_.empty() && sessionId != sessionId_) return; + if (!config_.recordAllSessions && !sessionId_.empty() && sessionId != sessionId_) return; std::string method = request.value("method", ""); - if (isControlMethod(method)) return; + if (!config_.recordControlMethods && isControlMethod(method)) return; WorkflowCall call; call.request = request; call.response = response; call.timestamp = currentTimestamp(); + call.sessionId = sessionId; + call.method = method; calls_.push_back(std::move(call)); } + void recordEvent(const std::string& type, const json& payload) { + if (!recording_ || replaying_ || !config_.recordEditorEvents) return; + WorkflowEvent ev; + ev.type = type; + ev.payload = payload; + ev.timestamp = currentTimestamp(); + events_.push_back(std::move(ev)); + } + json exportWorkflow() const { json out; out["name"] = name_; out["sessionId"] = sessionId_; out["createdAt"] = createdAt_; out["stoppedAt"] = stoppedAt_; + out["metadata"] = metadata_; json arr = json::array(); for (const auto& c : calls_) { arr.push_back({ {"timestamp", c.timestamp}, + {"sessionId", c.sessionId}, + {"method", c.method}, {"request", c.request}, {"response", c.response} }); } out["calls"] = arr; + json events = json::array(); + for (const auto& e : events_) { + events.push_back({ + {"timestamp", e.timestamp}, + {"type", e.type}, + {"payload", e.payload} + }); + } + out["events"] = events; return out; } @@ -76,14 +125,27 @@ public: sessionId_ = workflow.value("sessionId", ""); createdAt_ = workflow.value("createdAt", ""); stoppedAt_ = workflow.value("stoppedAt", ""); + metadata_ = workflow.value("metadata", json::object()); calls_.clear(); for (const auto& c : workflow["calls"]) { WorkflowCall call; call.timestamp = c.value("timestamp", ""); + call.sessionId = c.value("sessionId", ""); + call.method = c.value("method", ""); call.request = c.value("request", json::object()); call.response = c.value("response", json::object()); calls_.push_back(std::move(call)); } + events_.clear(); + if (workflow.contains("events") && workflow["events"].is_array()) { + for (const auto& e : workflow["events"]) { + WorkflowEvent ev; + ev.timestamp = e.value("timestamp", ""); + ev.type = e.value("type", ""); + ev.payload = e.value("payload", json::object()); + events_.push_back(std::move(ev)); + } + } return true; } @@ -105,12 +167,17 @@ public: createdAt_.clear(); stoppedAt_.clear(); calls_.clear(); + events_.clear(); + metadata_.clear(); recording_ = false; + config_ = RecordingConfig{}; } private: static std::string currentTimestamp() { auto now = std::chrono::system_clock::now(); + auto ms = std::chrono::duration_cast( + now.time_since_epoch()) % 1000; std::time_t tt = std::chrono::system_clock::to_time_t(now); std::tm tm{}; #ifdef _WIN32 @@ -119,7 +186,8 @@ private: localtime_r(&tt, &tm); #endif std::ostringstream oss; - oss << std::put_time(&tm, "%H:%M:%S"); + oss << std::put_time(&tm, "%Y-%m-%dT%H:%M:%S") + << '.' << std::setw(3) << std::setfill('0') << ms.count(); return oss.str(); } @@ -134,7 +202,10 @@ private: std::string sessionId_; std::string createdAt_; std::string stoppedAt_; + json metadata_; std::vector calls_; + std::vector events_; bool recording_ = false; bool replaying_ = false; + RecordingConfig config_; }; diff --git a/editor/src/panels/SettingsPanel.h b/editor/src/panels/SettingsPanel.h index 8658dc4..3eb7cfb 100644 --- a/editor/src/panels/SettingsPanel.h +++ b/editor/src/panels/SettingsPanel.h @@ -359,6 +359,18 @@ static void renderSettingsPanel(EditorState& state) { state.settings.setBlockVulnerableImports(blockVulnImports); settingsChanged = true; } + bool autoRecordSessions = state.settings.getAutoRecordSessions(); + if (ImGui::Checkbox("Auto-record Sessions", &autoRecordSessions)) { + state.settings.setAutoRecordSessions(autoRecordSessions); + if (autoRecordSessions && !state.agent.workflowRecorder.isRecording()) { + state.startSessionRecording("auto-session", true); + } else if (!autoRecordSessions && + state.agent.workflowRecorder.isRecording() && + state.agent.workflowRecorder.isAutoRecording()) { + state.stopSessionRecording(); + } + settingsChanged = true; + } ImGui::Separator(); ImGui::TextUnformatted("Large File Thresholds (MB)"); diff --git a/editor/src/panels/StatusBarPanel.h b/editor/src/panels/StatusBarPanel.h index 7e1668d..be1a1e1 100644 --- a/editor/src/panels/StatusBarPanel.h +++ b/editor/src/panels/StatusBarPanel.h @@ -143,6 +143,23 @@ static void renderStatusBar(EditorState& state) { state.notifications.showHistory = !state.notifications.showHistory; } + // Recording indicator + ImGui::SameLine(0, 10); + bool recording = state.agent.workflowRecorder.isRecording(); + if (recording) { + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.75f, 0.15f, 0.15f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.85f, 0.20f, 0.20f, 1.0f)); + } + if (ImGui::Button("REC")) { + state.toggleSessionRecording(); + } + if (ImGui::IsItemHovered()) { + ImGui::SetTooltip(recording ? "Session recording active" : "Start session recording"); + } + if (recording) { + ImGui::PopStyleColor(2); + } + // Right cluster: Ln/Col, selection, zoom, git int selStart = -1; int selEnd = -1; diff --git a/editor/tests/step230_test.cpp b/editor/tests/step230_test.cpp new file mode 100644 index 0000000..9e6a93b --- /dev/null +++ b/editor/tests/step230_test.cpp @@ -0,0 +1,48 @@ +// Step 230: Enhanced session recorder. +#include "WorkflowRecorder.h" +#include + +static void expect(bool cond, const std::string& name, int& passed, int& failed) { + if (cond) { + std::cout << "Test " << (passed + failed + 1) << " PASS: " << name << "\n"; + ++passed; + } else { + std::cout << "Test " << (passed + failed + 1) << " FAIL: " << name << "\n"; + ++failed; + } +} + +int main() { + int passed = 0; + int failed = 0; + + WorkflowRecorder rec; + WorkflowRecorder::RecordingConfig cfg; + cfg.recordAllSessions = true; + cfg.recordControlMethods = true; + cfg.recordEditorEvents = true; + cfg.autoRecording = true; + + json meta = {{"editorVersion", "0.3.0"}, {"os", "test"}}; + rec.startRecording("session", "", cfg, meta); + + json req = {{"jsonrpc", "2.0"}, {"id", 1}, {"method", "getAST"}}; + json res = {{"jsonrpc", "2.0"}, {"id", 1}, {"result", true}}; + rec.record("s1", req, res); + rec.recordEvent("file_opened", {"path", "main.py"}); + + json out = rec.stopRecording(); + expect(out.value("name", "") == "session", "recording name", passed, failed); + expect(out["metadata"].value("editorVersion", "") == "0.3.0", + "metadata preserved", passed, failed); + expect(out["calls"].size() == 1, "records rpc calls", passed, failed); + expect(out["calls"][0].value("sessionId", "") == "s1", "session id captured", passed, failed); + expect(out["events"].size() == 1, "records editor events", passed, failed); + expect(out["events"][0].value("type", "") == "file_opened", "event type captured", passed, failed); + expect(out["events"][0].value("timestamp", "").find('.') != std::string::npos, + "timestamp includes milliseconds", passed, failed); + + std::cout << "\n=== Step 230 Results: " << passed << " passed, " + << failed << " failed ===\n"; + return failed == 0 ? 0 : 1; +} diff --git a/sprint7_plan.md b/sprint7_plan.md index 6571d95..519357c 100644 --- a/sprint7_plan.md +++ b/sprint7_plan.md @@ -401,7 +401,7 @@ Optimized tool definitions and prompts for specific LLM families. Capture real editing sessions, anonymize them, and export as training data. -- [ ] **Step 230: Enhanced session recorder** +- [x] **Step 230: Enhanced session recorder** Extend `WorkflowRecorder` for full session capture: - Record all JSON-RPC traffic (not just agent-initiated) - Capture editor events: file open, buffer switch, theme change, etc.